diff --git a/.gitignore b/.gitignore index c2065bc..9c22a85 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ out/ ### VS Code ### .vscode/ +/src/empty/ diff --git a/build.gradle b/build.gradle index 2572503..8e69f22 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.5.6' - id 'io.spring.dependency-management' version '1.1.7' + id 'java' + id 'org.springframework.boot' version '3.5.6' + id 'io.spring.dependency-management' version '1.1.7' } group = 'springboot' @@ -9,42 +9,68 @@ version = '0.0.1-SNAPSHOT' description = 'Demo project for Spring Boot' java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } } + + + + repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + - // JPA - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + // JPA + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - // MySQL - implementation 'com.mysql:mysql-connector-j' + // MySQL + implementation 'com.mysql:mysql-connector-j' + implementation 'org.springframework.boot:spring-boot-starter-actuator' - // Lombok (compileOnly + annotationProcessor 조합) + // Lombok (compileOnly + annotationProcessor 조합) compileOnly 'org.projectlombok:lombok:1.18.30' annotationProcessor 'org.projectlombok:lombok:1.18.30' - // QueryDSL - implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' - annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" - annotationProcessor "jakarta.annotation:jakarta.annotation-api" - annotationProcessor "jakarta.persistence:jakarta.persistence-api" + // QueryDSL + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // Spring Security (웹 필터 및 인증 X) + implementation 'org.springframework.security:spring-security-crypto' + implementation 'org.springframework.boot:spring-boot-starter-security' + + //test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation platform('org.junit:junit-bom:5.10.2') // 최신 BOM + testImplementation 'org.junit.jupiter:junit-jupiter-api' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' + testImplementation 'org.assertj:assertj-core:3.22.0' + + //Jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.session:spring-session-data-redis' - // Spring Security (웹 필터 및 인증 X) - implementation 'org.springframework.security:spring-security-crypto' + // Password Encode Algorithm + implementation 'org.mindrot:jbcrypt:0.4' } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/src/main/java/springboot/kakao_boot_camp/KakaoBootCampApplication.java b/src/main/java/springboot/kakao_boot_camp/KakaoBootCampApplication.java index d1a7c2e..9831df3 100644 --- a/src/main/java/springboot/kakao_boot_camp/KakaoBootCampApplication.java +++ b/src/main/java/springboot/kakao_boot_camp/KakaoBootCampApplication.java @@ -2,12 +2,21 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -@SpringBootApplication +@SpringBootApplication( + exclude = {ValidationAutoConfiguration.class, + SecurityAutoConfiguration.class } // ← Security 자동설정 제외 + +) + +@EnableJpaAuditing public class KakaoBootCampApplication { - public static void main(String[] args) { - SpringApplication.run(KakaoBootCampApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(KakaoBootCampApplication.class, args); + } -} +} \ No newline at end of file diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/controller/LoginController.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/controller/LoginController.java new file mode 100644 index 0000000..5aec3a0 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/controller/LoginController.java @@ -0,0 +1,47 @@ +package springboot.kakao_boot_camp.domain.auth.controller; + +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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import springboot.kakao_boot_camp.domain.auth.util.Manager.login.jwt.RefreshTokenCookieManager; +import springboot.kakao_boot_camp.domain.auth.dto.loginDtos.LoginReq; +import springboot.kakao_boot_camp.domain.auth.dto.loginDtos.LoginRes; +import springboot.kakao_boot_camp.domain.auth.service.LoginService; +import springboot.kakao_boot_camp.global.api.ApiResponse; +import springboot.kakao_boot_camp.global.api.SuccessCode; + +@RequestMapping("/api/v1/auth/login") +@RequiredArgsConstructor +@RestController +public class LoginController { + + private final LoginService loginService; + private final RefreshTokenCookieManager refreshTokenCookieManager; + + + @PostMapping + public ResponseEntity> login( + @RequestBody @Valid LoginReq req, + HttpServletRequest servletRequest, + HttpServletResponse servletResponse) { + + // 1. 액세스 토큰 포함 DTO 생성 + LoginRes res = loginService.login(req, servletRequest); + + // 2. 리프레시 토큰 포함 + refreshTokenCookieManager.addRefreshTokenCookie(servletResponse, res.refreshToken()); + + // 3. Login response Dto에서 Refresh token 빼기 + LoginRes result = LoginRes.fromWithoutRefreshToken(res.userId(), res.accessToken()); + return ResponseEntity + .ok(ApiResponse.success(SuccessCode.LOGIN_SUCCESS, result)); + } + + +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/controller/LogoutController.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/controller/LogoutController.java new file mode 100644 index 0000000..6b14f39 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/controller/LogoutController.java @@ -0,0 +1,31 @@ +package springboot.kakao_boot_camp.domain.auth.controller; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import springboot.kakao_boot_camp.domain.auth.service.LogoutService; +import springboot.kakao_boot_camp.global.api.ApiResponse; +import springboot.kakao_boot_camp.global.api.SuccessCode; +import springboot.kakao_boot_camp.security.CustomUserDetails; + +@RestController +@RequestMapping("/api/v1/auth/logout") +@RequiredArgsConstructor +public class LogoutController { + + private final LogoutService logoutService; + + @PostMapping + public ResponseEntity> logout(HttpServletRequest request, HttpServletResponse response, + @AuthenticationPrincipal CustomUserDetails currentUser) { + + logoutService.logout(currentUser, request, response); + + return ResponseEntity.ok(ApiResponse.success(SuccessCode.LOGOUT_SUCCESS, null)); + } +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/controller/AuthController.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/controller/SignUpController.java similarity index 65% rename from src/main/java/springboot/kakao_boot_camp/domain/auth/controller/AuthController.java rename to src/main/java/springboot/kakao_boot_camp/domain/auth/controller/SignUpController.java index e8279b0..b21e9e4 100644 --- a/src/main/java/springboot/kakao_boot_camp/domain/auth/controller/AuthController.java +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/controller/SignUpController.java @@ -10,19 +10,19 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import springboot.kakao_boot_camp.domain.auth.dto.AuthDtos.*; +import springboot.kakao_boot_camp.domain.auth.dto.signDtos.SignReq; +import springboot.kakao_boot_camp.domain.auth.dto.signDtos.SignRes; import springboot.kakao_boot_camp.global.api.ApiResponse; import springboot.kakao_boot_camp.global.api.SuccessCode; -import springboot.kakao_boot_camp.domain.auth.service.AuthService; +import springboot.kakao_boot_camp.domain.auth.service.SignUpService; @RequiredArgsConstructor @RestController // v1, v2 같은 버전은 추후 버전 관리를 위해 필요한 것인데 해당 프로젝트는 학습용 이므로 추후에 유지 보수 예정 X -> 따라서 버전 명 명시 안할 예정 -@RequestMapping("/api/v1/auth") -public class AuthController { +@RequestMapping("/api/v1/auth/signup") +public class SignUpController { + private final SignUpService authService; - private final AuthService authService; - - @PostMapping("/signup") + @PostMapping public ResponseEntity> signUp(@RequestBody @Valid SignReq req, HttpServletResponse servletRes) { SignRes res = authService.signUp(req); //data 얻기 @@ -30,13 +30,4 @@ public ResponseEntity> signUp(@RequestBody @Valid SignReq r .status(HttpStatus.OK) .body(ApiResponse.success(SuccessCode.REGISTER_SUCCESS, res)); } - - @PostMapping("/login") - public ResponseEntity> login(@RequestBody @Valid LoginReq req, HttpServletResponse servletRes) { - LoginRes res = authService.login(req); //data 얻기 - - return ResponseEntity - .status(HttpStatus.OK) - .body(ApiResponse.success(SuccessCode.LOGIN_SUCCESS, res)); - } -} +} \ No newline at end of file diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/controller/TokenRefreshController.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/controller/TokenRefreshController.java new file mode 100644 index 0000000..f0f08ae --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/controller/TokenRefreshController.java @@ -0,0 +1,49 @@ +package springboot.kakao_boot_camp.domain.auth.controller; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import springboot.kakao_boot_camp.domain.auth.util.Manager.login.jwt.RefreshTokenCookieManager; +import springboot.kakao_boot_camp.domain.auth.dto.loginDtos.LoginRes; +import springboot.kakao_boot_camp.domain.auth.service.TokenRefreshService; +import springboot.kakao_boot_camp.global.api.ApiResponse; +import springboot.kakao_boot_camp.global.api.SuccessCode; +import springboot.kakao_boot_camp.security.CustomUserDetails; + +@RestController +@RequestMapping("/api/v1/auth/token") +@RequiredArgsConstructor +public class TokenRefreshController { + + private final RefreshTokenCookieManager refreshTokenCookieManager; + private final TokenRefreshService tokenRefreshService; + + /** + * 🔄 Refresh Token을 이용해 Access Token 재발급 + */ + @PostMapping("/refresh") + public ResponseEntity> refreshAccessToken( + HttpServletRequest request, + HttpServletResponse response, + @AuthenticationPrincipal CustomUserDetails currentUser + ) { + + // 1️⃣ 쿠키에서 refresh token 추출 + String refreshToken = refreshTokenCookieManager.getRefreshTokenFromCookie(request); + + // 2️⃣ 새 access + refresh token 생성 + LoginRes newTokens = tokenRefreshService.refreshTokens(currentUser, refreshToken); + + // 3️⃣ 새 refresh token을 쿠키에 다시 설정 + refreshTokenCookieManager.addRefreshTokenCookie(response, newTokens.refreshToken()); + + // 4️⃣ refresh token은 response에 포함하지 않음 + LoginRes result = LoginRes.fromWithoutRefreshToken(newTokens.userId(), newTokens.accessToken()); + return ResponseEntity.ok(ApiResponse.success(SuccessCode.TOKEN_REFERSH_SUCCESS, result)); + } +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/dto/AuthDtos.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/dto/AuthDtos.java deleted file mode 100644 index 14cd813..0000000 --- a/src/main/java/springboot/kakao_boot_camp/domain/auth/dto/AuthDtos.java +++ /dev/null @@ -1,55 +0,0 @@ -package springboot.kakao_boot_camp.domain.auth.dto; - -import jakarta.validation.constraints.Email; -import lombok.NonNull; -import springboot.kakao_boot_camp.domain.user.entity.User; - -public class AuthDtos { - - - // 1. Sign Up - public record SignReq( - @Email - @NonNull - String email, - - @NonNull - String passWord, - - String nickName, - - String profileImage - ) { - } - - public record SignRes( - long id - ) { - - } - - // 2. Todo : login - - public record LoginReq( - String email, - String passWord - ) { - } - - public record LoginRes( - Long userId, - String email, - String nickName, - String accessToken - ) { - public static LoginRes from(User user, String accessToken) { - return new LoginRes( - user.getId(), - user.getEmail(), - user.getNickName(), - accessToken - ); - - } - } -} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/dto/loginDtos/LoginReq.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/dto/loginDtos/LoginReq.java new file mode 100644 index 0000000..63d0cdb --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/dto/loginDtos/LoginReq.java @@ -0,0 +1,7 @@ +package springboot.kakao_boot_camp.domain.auth.dto.loginDtos; + +public record LoginReq( + String email, + String passWord +) { +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/dto/loginDtos/LoginRes.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/dto/loginDtos/LoginRes.java new file mode 100644 index 0000000..9c540b9 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/dto/loginDtos/LoginRes.java @@ -0,0 +1,25 @@ +package springboot.kakao_boot_camp.domain.auth.dto.loginDtos; + +import springboot.kakao_boot_camp.security.CustomUserDetails; + +public record LoginRes( + Long userId, + String accessToken, + String refreshToken +) { + public static LoginRes from(CustomUserDetails user, String accessToken, String refreshToken) { + return new LoginRes( + user.getId(), + accessToken, + refreshToken + ); + } + + public static LoginRes fromWithoutRefreshToken(Long userId, String accessToken) { + return new LoginRes( + userId, + accessToken, + null + ); + } +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/dto/refresh/TokenResponse.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/dto/refresh/TokenResponse.java new file mode 100644 index 0000000..ce67838 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/dto/refresh/TokenResponse.java @@ -0,0 +1,11 @@ +package springboot.kakao_boot_camp.domain.auth.dto.refresh; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class TokenResponse { + private String accessToken; + private String refreshToken; +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/dto/signDtos/SignReq.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/dto/signDtos/SignReq.java new file mode 100644 index 0000000..de4e8ac --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/dto/signDtos/SignReq.java @@ -0,0 +1,18 @@ +package springboot.kakao_boot_camp.domain.auth.dto.signDtos; + +import jakarta.validation.constraints.Email; +import lombok.NonNull; + +// 1. Sign Up +public record SignReq( + @Email + @NonNull + String email, + + @NonNull + String passWord, + + String nickName + +) { +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/dto/signDtos/SignRes.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/dto/signDtos/SignRes.java new file mode 100644 index 0000000..a0df9b3 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/dto/signDtos/SignRes.java @@ -0,0 +1,7 @@ +package springboot.kakao_boot_camp.domain.auth.dto.signDtos; + +public record SignRes( + long id +) { + +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/exception/InvalidTokenTypeException.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/exception/InvalidTokenTypeException.java new file mode 100644 index 0000000..b3ae535 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/exception/InvalidTokenTypeException.java @@ -0,0 +1,11 @@ +package springboot.kakao_boot_camp.domain.auth.exception; + + +import springboot.kakao_boot_camp.global.api.ErrorCode; +import springboot.kakao_boot_camp.global.exception.BusinessException; + +public class InvalidTokenTypeException extends BusinessException { + public InvalidTokenTypeException(){ + super(ErrorCode.INVALID_TOKEN_TYPE); + } +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/exception/JwtTokenExpiredException.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/exception/JwtTokenExpiredException.java new file mode 100644 index 0000000..8136de0 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/exception/JwtTokenExpiredException.java @@ -0,0 +1,10 @@ +package springboot.kakao_boot_camp.domain.auth.exception; + +import springboot.kakao_boot_camp.global.api.ErrorCode; +import springboot.kakao_boot_camp.global.exception.BusinessException; + +public class JwtTokenExpiredException extends BusinessException { + public JwtTokenExpiredException(){ + super(ErrorCode.TOKEN_EXPIRED); + } +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/exception/NotAuthenticateUser.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/exception/NotAuthenticateUser.java new file mode 100644 index 0000000..97c2d9e --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/exception/NotAuthenticateUser.java @@ -0,0 +1,11 @@ +package springboot.kakao_boot_camp.domain.auth.exception; + +import springboot.kakao_boot_camp.global.api.ErrorCode; +import springboot.kakao_boot_camp.global.exception.BusinessException; + +public class NotAuthenticateUser extends BusinessException { + public NotAuthenticateUser() { + super(ErrorCode.UNAUTHORIZED); + } + +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/exception/SessionExpiredException.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/exception/SessionExpiredException.java new file mode 100644 index 0000000..4cbba09 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/exception/SessionExpiredException.java @@ -0,0 +1,10 @@ +package springboot.kakao_boot_camp.domain.auth.exception; + +import springboot.kakao_boot_camp.global.api.ErrorCode; +import springboot.kakao_boot_camp.global.exception.BusinessException; + +public class SessionExpiredException extends BusinessException { + public SessionExpiredException(){ + super(ErrorCode.SESSION_EXPIRED); + } +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/service/LoginService.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/service/LoginService.java new file mode 100644 index 0000000..a53428c --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/service/LoginService.java @@ -0,0 +1,49 @@ +package springboot.kakao_boot_camp.domain.auth.service; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import springboot.kakao_boot_camp.domain.auth.util.Manager.login.jwt.JwtAuthManager; +import springboot.kakao_boot_camp.domain.auth.dto.loginDtos.LoginReq; +import springboot.kakao_boot_camp.domain.auth.dto.loginDtos.LoginRes; +import springboot.kakao_boot_camp.domain.auth.exception.InvalidLoginException; +import springboot.kakao_boot_camp.security.CustomUserDetails; + + +@Service +@RequiredArgsConstructor +public class LoginService { + private final JwtAuthManager jwtAuthManager; + private final AuthenticationManagerBuilder authenticationManagerBuilder; + + + // 1. jwt 기반 로그인 + public LoginRes login(LoginReq req, HttpServletRequest servletReq) throws RuntimeException { + + // 1. 토큰 생성 + var token = new UsernamePasswordAuthenticationToken(req.email(), req.passWord()); + + // 2. 로그인 검증 + try { + var auth = authenticationManagerBuilder.getObject().authenticate(token); + + + JwtAuthManager.TokenPair tokenPair = jwtAuthManager.createTokens(auth); + + + return LoginRes.from((CustomUserDetails) auth.getPrincipal(), tokenPair.accessToken(), tokenPair.refreshToken()); + + } catch (BadCredentialsException | UsernameNotFoundException e) { + throw new InvalidLoginException(); + } catch (AuthenticationException e) { + throw new InvalidLoginException(); + } + } + +} + diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/service/LogoutService.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/service/LogoutService.java new file mode 100644 index 0000000..6575dfa --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/service/LogoutService.java @@ -0,0 +1,94 @@ +package springboot.kakao_boot_camp.domain.auth.service; + +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.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import springboot.kakao_boot_camp.domain.auth.util.Manager.login.jwt.TokenBlacklistManager; +import springboot.kakao_boot_camp.domain.auth.exception.JwtTokenExpiredException; +import springboot.kakao_boot_camp.domain.auth.exception.NotAuthenticateUser; +import springboot.kakao_boot_camp.domain.auth.util.JwtUtil; +import io.jsonwebtoken.Claims; +import springboot.kakao_boot_camp.security.CustomUserDetails; + +import java.time.Instant; + +@Slf4j +@Service +@RequiredArgsConstructor +public class LogoutService { + + private final JwtUtil jwtUtil; + private final TokenBlacklistManager tokenBlacklistManager; + + + public void logout(CustomUserDetails currentUser, HttpServletRequest request, HttpServletResponse response) { + + + // 1. 인증 상태 검증 + if (currentUser == null) { + throw new NotAuthenticateUser(); + } + + + // 2. 쿠키 검증 + Cookie[] cookies = request.getCookies(); + if (cookies == null) return; + + + // 3. 리프레시 토큰 검증 + for (Cookie cookie : cookies) { + if ("refreshToken".equals(cookie.getName())) { + + // 3.1 리프레시 토큰 획득 + String refreshToken = cookie.getValue(); + if (refreshToken == null || refreshToken.isEmpty()) return; + + + // 3.2 클레임 토큰 획득 + Claims claims; + try { + claims = jwtUtil.extractRefreshToken(refreshToken); + } catch (Exception e) { + throw new JwtTokenExpiredException(); + } + + // 3.3 토큰의 userId와 현재 인증된 user 비교 + Long tokenUserId; + try { + tokenUserId = claims.get("userId", Number.class).longValue(); + } catch (Exception e) { + throw new AccessDeniedException("Invalid token structure"); + } + if (!tokenUserId.equals(currentUser.getId())) { + throw new AccessDeniedException("Token does not belong to current user"); + } + + + // 3.4 jti 및 만료시간 계산 후 블랙리스트 추가 + String jti = claims.getId(); + long expMillis = claims.getExpiration().getTime() - System.currentTimeMillis(); + if (jti != null && expMillis > 0) { + tokenBlacklistManager.add(jti, Instant.now().plusMillis(expMillis)); + } + + + // 3.5 클라이언트 쿠키 제거 + Cookie del = new Cookie("refreshToken", null); + del.setHttpOnly(true); + del.setSecure(true); + del.setPath("/"); + del.setMaxAge(0); + response.addCookie(del); + + + // 3.6 쿠키 더 순회하지 않고 종료 + break; + } + } + } +} + diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/service/MyUserDetailsService.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/service/MyUserDetailsService.java new file mode 100644 index 0000000..1467c8d --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/service/MyUserDetailsService.java @@ -0,0 +1,59 @@ +package springboot.kakao_boot_camp.domain.auth.service; + + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +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; +import springboot.kakao_boot_camp.domain.user.enums.UserRole; +import springboot.kakao_boot_camp.domain.user.exception.UserNotFoundException; +import springboot.kakao_boot_camp.domain.user.repository.UserRepository; +import springboot.kakao_boot_camp.security.CustomUserDetails; + +import java.util.ArrayList; +import java.util.List; + + +// 스프링 시큐리티에서 자동으로 호출 + +@Service +@RequiredArgsConstructor +public class MyUserDetailsService implements UserDetailsService { + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + List auth = new ArrayList<>(); + + + // 유저 찾고 + springboot.kakao_boot_camp.domain.user.model.User user = userRepository.findByEmail(email) + .orElseThrow(UserNotFoundException::new); + + + // 역할 부여하고 + + if (user.getRole() != null){ + + } + if (user.getRole().equals(UserRole.ROLE_USER)) { + auth.add(new SimpleGrantedAuthority(UserRole.ROLE_USER.name())); + } + else if(user.getRole().equals(UserRole.ROLE_ADMIN)){ + auth.add(new SimpleGrantedAuthority((UserRole.ROLE_ADMIN.name()))); + } + + // 프로바이더가 토큰으로 받은 비밀번호와 비교할 DB에서 조회한 User객체 반환 -> 해당 유저 객체의 비밀번호를 프로바이더가 사용한다. + CustomUserDetails customUserDetails = new CustomUserDetails(user, auth); // email, password, authorities 등록 + customUserDetails.setId(user.getId()); + + return customUserDetails; + + } + + +} + diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/service/AuthService.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/service/SignUpService.java similarity index 51% rename from src/main/java/springboot/kakao_boot_camp/domain/auth/service/AuthService.java rename to src/main/java/springboot/kakao_boot_camp/domain/auth/service/SignUpService.java index ab0d4f1..f4ec544 100644 --- a/src/main/java/springboot/kakao_boot_camp/domain/auth/service/AuthService.java +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/service/SignUpService.java @@ -2,26 +2,27 @@ import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; -import springboot.kakao_boot_camp.domain.auth.dto.AuthDtos.*; +import springboot.kakao_boot_camp.domain.auth.util.Manager.signup.SignUpManager; +import springboot.kakao_boot_camp.domain.auth.dto.signDtos.SignReq; +import springboot.kakao_boot_camp.domain.auth.dto.signDtos.SignRes; import springboot.kakao_boot_camp.domain.auth.exception.DuplicateEmailException; -import springboot.kakao_boot_camp.domain.auth.exception.InvalidLoginException; -import springboot.kakao_boot_camp.domain.user.entity.User; -import springboot.kakao_boot_camp.global.api.ErrorCode; -import springboot.kakao_boot_camp.global.constant.DefaultImage; -import springboot.kakao_boot_camp.global.exception.DuplicateResourceException; -import springboot.kakao_boot_camp.domain.user.repository.UserRepo; -import org.springframework.security.crypto.password.PasswordEncoder; +import springboot.kakao_boot_camp.domain.user.enums.UserRole; +import springboot.kakao_boot_camp.domain.user.model.User; +import springboot.kakao_boot_camp.domain.user.repository.UserRepository; +//import springboot.kakao_boot_camp.global.util.JwtUtil; import java.time.LocalDateTime; @Service @RequiredArgsConstructor -public class AuthService { //Dto로 컨트롤러에서 받음 +public class SignUpService { //Dto로 컨트롤러에서 받음 - private final UserRepo userRepo; + private final UserRepository userRepo; private final PasswordEncoder passwordEncoder; + private final SignUpManager signUpManager; public SignRes signUp(SignReq req) throws RuntimeException { @@ -32,13 +33,19 @@ public SignRes signUp(SignReq req) throws RuntimeException { } + UserRole userRole = UserRole.ROLE_USER; + + if (signUpManager.isAdmin( req.email())){ + userRole=UserRole.ROLE_ADMIN; + } + User user = User.builder() .email(req.email()) .passWord(passwordEncoder.encode(req.passWord())) .nickName(req.nickName()) - .profileImage(req.profileImage()) - .posts(null) - .cratedAt(LocalDateTime.now()) + .profileImage(null) + .role(userRole) + .createdAt(LocalDateTime.now()) .updatedAt(LocalDateTime.now()) .build(); @@ -50,23 +57,8 @@ public SignRes signUp(SignReq req) throws RuntimeException { return new SignRes(savedUSer.getId()); } - public LoginRes login(LoginReq req) throws RuntimeException{ - String accessTokenSample = "asfdafdfadsasdfadfsa"; - - User user = userRepo.findByEmail(req.email()) - .orElseThrow(() -> new InvalidLoginException()); - - if(!passwordEncoder.matches(req.passWord(), user.getPassWord())){ - throw new InvalidLoginException(); - } - - - return LoginRes.from(user, accessTokenSample); - - } - } diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/service/TokenRefreshService.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/service/TokenRefreshService.java new file mode 100644 index 0000000..c2731b3 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/service/TokenRefreshService.java @@ -0,0 +1,62 @@ +package springboot.kakao_boot_camp.domain.auth.service; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import springboot.kakao_boot_camp.domain.auth.util.Manager.login.jwt.JwtAuthManager; +import springboot.kakao_boot_camp.domain.auth.util.Manager.login.jwt.TokenBlacklistManager; +import springboot.kakao_boot_camp.domain.auth.dto.loginDtos.LoginRes; +import springboot.kakao_boot_camp.domain.auth.util.JwtUtil; +import springboot.kakao_boot_camp.security.CustomUserDetails; + +@Service +@RequiredArgsConstructor +public class TokenRefreshService { + + private final JwtAuthManager jwtAuthManager; + private final TokenBlacklistManager tokenBlacklistManager; + private final JwtUtil jwtUtil; + + public LoginRes refreshTokens(CustomUserDetails customUserDetails, String refreshToken) { + + if (refreshToken == null || refreshToken.isBlank()) { + throw new RuntimeException("리프레시 토큰이 없습니다."); + } + + // 🔒 블랙리스트 확인 + if (tokenBlacklistManager.isBlacklisted(refreshToken)) { + throw new RuntimeException("유효하지 않은 리프레시 토큰입니다."); + } + + // ✅ 토큰 유효성 검증 + if (!jwtUtil.validateToken(refreshToken, false)) { + throw new RuntimeException("리프레시 토큰이 만료되었거나 유효하지 않습니다."); + } + + // Claims 추출 + Claims claims = jwtUtil.extractRefreshToken(refreshToken); + Long userId = claims.get("userId", Long.class); + + if (!customUserDetails.getId().equals(userId)) { + throw new RuntimeException("토큰 사용자 정보와 일치하지 않습니다."); + } + + // Authentication 객체 생성 + Authentication auth = new UsernamePasswordAuthenticationToken( + customUserDetails, + null, + customUserDetails.getAuthorities() + ); + + // 새 토큰 발급 + JwtAuthManager.TokenPair tokenPair = jwtAuthManager.createTokens(auth); + + return new LoginRes( + customUserDetails.getId(), + tokenPair.accessToken(), + tokenPair.refreshToken() + ); + } +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/util/CustomPasswordEncoder.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/util/CustomPasswordEncoder.java new file mode 100644 index 0000000..f97c9b1 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/util/CustomPasswordEncoder.java @@ -0,0 +1,15 @@ +package springboot.kakao_boot_camp.domain.auth.util; + +import org.springframework.stereotype.Component; +import org.mindrot.jbcrypt.BCrypt; + +@Component +public class CustomPasswordEncoder { + public String encode(String raw) { + return BCrypt.hashpw(raw, BCrypt.gensalt()); + } + + public boolean match(String raw, String hashedPassword) { + return BCrypt.checkpw(raw, hashedPassword); + } +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/util/JwtUtil.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/util/JwtUtil.java new file mode 100644 index 0000000..a85695c --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/util/JwtUtil.java @@ -0,0 +1,125 @@ +package springboot.kakao_boot_camp.domain.auth.util; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.UUID; +import java.util.stream.Collectors; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import springboot.kakao_boot_camp.domain.auth.exception.InvalidTokenTypeException; +import springboot.kakao_boot_camp.domain.auth.exception.JwtTokenExpiredException; +import springboot.kakao_boot_camp.global.api.ErrorCode; +import springboot.kakao_boot_camp.security.CustomUserDetails; + + +@Component +public class JwtUtil { + + private final SecretKey accessKey; + private final SecretKey refreshKey; + private final long accessTtl; + private final long refreshTtl; + + // 생성자 주입 + public JwtUtil( + @Value("${jwt.access.secret}") String accessSecret, + @Value("${jwt.refresh.secret}") String refreshSecret, + @Value("${jwt.access.expiration}") long accessTtl, + @Value("${jwt.refresh.expiration}") long refreshTtl + ) { + this.accessKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(accessSecret)); + this.refreshKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(refreshSecret)); + this.accessTtl = accessTtl; + this.refreshTtl = refreshTtl; + } + + // Access Token 생성 + public String createAccessToken(Authentication auth) { + return createToken(auth, accessKey, accessTtl); + } + + // Refresh Token 생성 + public String createRefreshToken(Authentication auth) { + return createToken(auth, refreshKey, refreshTtl); + } + + // 공통 토큰 생성 로직 + private String createToken(Authentication auth, SecretKey key, long ttl) { + CustomUserDetails user = (CustomUserDetails) auth.getPrincipal(); + + String authorities = auth.getAuthorities().stream() //getAuthorities -> List return + .map(a -> a.getAuthority()) // getAuthority() -> String return + .collect(Collectors.joining(",")); + + return Jwts.builder() + .setId(UUID.randomUUID().toString()) // <-- jti 추가 + .claim("userId", user.getId()) + .claim("email", user.getUsername()) + .claim("role", authorities) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + ttl)) + .signWith(key) + .compact(); + } + + // Access Token 파싱 + public Claims extractAccessToken(String token) { + return extractToken(token, accessKey); + } + + // Refresh Token 파싱 + public Claims extractRefreshToken(String token) { + return extractToken(token, refreshKey); + } + + // 공통 파싱 로직 + public static Claims extractToken(String token, SecretKey key) { + try { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (ExpiredJwtException e) { + // 토큰 만료 예외를 커스텀 예외로 던짐 + throw new JwtTokenExpiredException(); + } catch (JwtException | IllegalArgumentException e) { + // 잘못된 토큰 예외를 커스텀 예외로 + System.out.println("JWT Parsing Error: " + e.getClass().getSimpleName() + " - " + e.getMessage()); + e.printStackTrace(); + + throw new InvalidTokenTypeException(); + } + + } + + // 토큰 유효성 검증 (Access / Refresh 구분) + public boolean validateToken(String token, boolean isAccessToken) { + try { + if (isAccessToken) { + extractAccessToken(token); + } else { + extractRefreshToken(token); + } + return true; // 예외가 안 나면 유효 + } catch (JwtTokenExpiredException e) { + System.out.println("토큰 만료: " + e.getMessage()); + return false; + } catch (InvalidTokenTypeException | JwtException e) { + System.out.println("유효하지 않은 토큰: " + e.getMessage()); + return false; + } + } +} + + + diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/util/Manager/login/jwt/JwtAuthManager.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/util/Manager/login/jwt/JwtAuthManager.java new file mode 100644 index 0000000..2e50a72 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/util/Manager/login/jwt/JwtAuthManager.java @@ -0,0 +1,26 @@ +package springboot.kakao_boot_camp.domain.auth.util.Manager.login.jwt; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; +import springboot.kakao_boot_camp.domain.auth.util.JwtUtil; + + +@Component +@RequiredArgsConstructor +public class JwtAuthManager { + private final JwtUtil jwtUtil; + + public TokenPair createTokens(Authentication token){ + String accessToken = jwtUtil.createAccessToken(token); + String refreshToken = jwtUtil.createRefreshToken(token); + + return TokenPair.from(accessToken, refreshToken); + } + + public record TokenPair(String accessToken, String refreshToken) { + public static TokenPair from(String accessToken, String refreshToken){ + return new TokenPair(accessToken, refreshToken); + } + } +} \ No newline at end of file diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/util/Manager/login/jwt/RefreshTokenCookieManager.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/util/Manager/login/jwt/RefreshTokenCookieManager.java new file mode 100644 index 0000000..6f85ab8 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/util/Manager/login/jwt/RefreshTokenCookieManager.java @@ -0,0 +1,52 @@ +package springboot.kakao_boot_camp.domain.auth.util.Manager.login.jwt; + + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; + +@Component +public class RefreshTokenCookieManager { + + + private static final String COOKIE_NAME = "refreshToken"; + private static final int MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7일 + + /** + * HttpOnly, Secure 쿠키로 리프레시 토큰 세팅 + */ + public void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) { + Cookie refreshCookie = new Cookie(COOKIE_NAME, refreshToken); + refreshCookie.setHttpOnly(true); // JS에서 접근 불가 + refreshCookie.setSecure(true); // HTTPS 환경에서만 전송 + refreshCookie.setPath("/"); // 전체 경로에서 사용 + refreshCookie.setMaxAge(MAX_AGE_SECONDS); + response.addCookie(refreshCookie); + } + + /** + * 로그아웃 시 쿠키 제거 + */ + public void clearRefreshTokenCookie(HttpServletResponse response) { + Cookie refreshCookie = new Cookie(COOKIE_NAME, null); + refreshCookie.setHttpOnly(true); + refreshCookie.setSecure(true); + refreshCookie.setPath("/"); + refreshCookie.setMaxAge(0); // 즉시 삭제 + response.addCookie(refreshCookie); + } + + + public String getRefreshTokenFromCookie(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) return null; + + for (Cookie cookie : cookies) { + if (COOKIE_NAME.equals(cookie.getName())) { + return cookie.getValue(); + } + } + return null; // 없으면 null 반환 + } +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/util/Manager/login/jwt/TokenBlacklistManager.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/util/Manager/login/jwt/TokenBlacklistManager.java new file mode 100644 index 0000000..9a576ec --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/util/Manager/login/jwt/TokenBlacklistManager.java @@ -0,0 +1,37 @@ +package springboot.kakao_boot_camp.domain.auth.util.Manager.login.jwt; + +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class TokenBlacklistManager { + + // key: JWT ID (jti), value: 만료 시간 + private final Map blacklist = new ConcurrentHashMap<>(); + + // 블랙리스트 등록 + public void add(String jti, Instant expiration) { + blacklist.put(jti, expiration); + } + + // 블랙리스트 체크 + public boolean isBlacklisted(String jti) { + Instant exp = blacklist.get(jti); + if (exp == null) return false; + + // 이미 만료된 토큰은 제거 + if (Instant.now().isAfter(exp)) { + blacklist.remove(jti); + return false; + } + return true; + } + + // 테스트용: 전체 제거 + public void clear() { + blacklist.clear(); + } +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/auth/util/Manager/signup/SignUpManager.java b/src/main/java/springboot/kakao_boot_camp/domain/auth/util/Manager/signup/SignUpManager.java new file mode 100644 index 0000000..d615004 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/auth/util/Manager/signup/SignUpManager.java @@ -0,0 +1,18 @@ +package springboot.kakao_boot_camp.domain.auth.util.Manager.signup; + +import org.springframework.stereotype.Component; + +@Component +public class SignUpManager { + + private String amdinEmail="admin@admin.com"; + + public boolean isAdmin(Object object){ + if(object.equals(amdinEmail)){ + return true; + } + return false; + } + + +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/comment/controller/CommentController.java b/src/main/java/springboot/kakao_boot_camp/domain/comment/controller/CommentController.java new file mode 100644 index 0000000..cbe6f7a --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/comment/controller/CommentController.java @@ -0,0 +1,80 @@ +package springboot.kakao_boot_camp.domain.comment.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import springboot.kakao_boot_camp.domain.comment.dto.create.CommentCreateReq; +import springboot.kakao_boot_camp.domain.comment.dto.create.CommentCreateRes; +import springboot.kakao_boot_camp.domain.comment.dto.delete.CommentDeleteRes; +import springboot.kakao_boot_camp.domain.comment.dto.read.CommentDetailRes; +import springboot.kakao_boot_camp.domain.comment.dto.read.CommentListRes; +import springboot.kakao_boot_camp.domain.comment.dto.update.CommentUpdateReq; +import springboot.kakao_boot_camp.domain.comment.dto.update.CommentUpdateRes; +import springboot.kakao_boot_camp.domain.comment.service.CommentService; +import springboot.kakao_boot_camp.global.api.ApiResponse; +import springboot.kakao_boot_camp.global.api.SuccessCode; +import springboot.kakao_boot_camp.security.CustomUserDetails; + +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class CommentController { + private final CommentService commentService; + + // -- 1. 댓글 생성 -- + @PostMapping("/posts/{postId}/comments") + public ResponseEntity> create(@PathVariable Long postId, + @RequestBody CommentCreateReq commentCreateReq, @AuthenticationPrincipal CustomUserDetails currentUser) { + CommentCreateRes res = commentService.createComment(currentUser.getId(), postId, commentCreateReq); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(SuccessCode.COMMENT_CREATE_SUCCESS, res)); + } + + // -- 2. 댓글 조회 -- + @GetMapping("/posts/{postId}/comments") + public ResponseEntity> getCommentList(@PathVariable Long postId, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { + CommentListRes res = commentService.getCommentList(postId, page, size); + + if (res.comments().isEmpty()) { + return ResponseEntity.ok(ApiResponse.success(SuccessCode.COMMENT_LIST_EMPTY, res)); + } + + return ResponseEntity.ok(ApiResponse.success(SuccessCode.COMMENT_LIST_READ_SUCCESS, res)); + } + @GetMapping("/comments/{commentId}") + public ResponseEntity> getCommentDetail(@PathVariable Long commentId) { + CommentDetailRes res = commentService.getCommentDetail(commentId); + return ResponseEntity.ok(ApiResponse.success(SuccessCode.COMMENT_READ_SUCCESS, res)); + } + + + // -- 3. 댓글 수정 -- + @PatchMapping("/comments/{commentId}") + public ResponseEntity> updateComment( + @PathVariable Long commentId, + @RequestBody CommentUpdateReq req + ) { + CommentUpdateRes res = commentService.updateComment(commentId, req); + return ResponseEntity.ok(ApiResponse.success(SuccessCode.COMMENT_UPDATE_SUCCESS, res)); + } + + + // -- 4. 댓글 삭제 -- + @DeleteMapping("/comments/{commentId}") + public ResponseEntity> deleteComment( + @PathVariable Long commentId + ) { + CommentDeleteRes res = commentService.deleteComment(commentId); + return ResponseEntity.ok(ApiResponse.success(SuccessCode.COMMENT_DELETE_SUCCESS, res)); + } + + +} + + + + + diff --git a/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/create/CommentCreateReq.java b/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/create/CommentCreateReq.java new file mode 100644 index 0000000..c198baf --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/create/CommentCreateReq.java @@ -0,0 +1,15 @@ +package springboot.kakao_boot_camp.domain.comment.dto.create; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +// Todo. 각 리코드 분리 필요 +// -- C -- +public record CommentCreateReq( + Long parentId, + + @NotBlank(message = "댓글 내용은 비워둘 수 없습니다.") + @Size(max = 1000, message = "댓글은 최대 1000자까지 입력 가능합니다.") + String content +) { +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/create/CommentCreateRes.java b/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/create/CommentCreateRes.java new file mode 100644 index 0000000..cfbef7c --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/create/CommentCreateRes.java @@ -0,0 +1,13 @@ +package springboot.kakao_boot_camp.domain.comment.dto.create; + +import springboot.kakao_boot_camp.domain.comment.entity.Comment; + +public record CommentCreateRes( + Long commentId +) { + public static CommentCreateRes from(Comment comment) { + return new CommentCreateRes( + comment.getId() + ); + } +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/delete/CommentDeleteRes.java b/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/delete/CommentDeleteRes.java new file mode 100644 index 0000000..c2c786d --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/delete/CommentDeleteRes.java @@ -0,0 +1,13 @@ +package springboot.kakao_boot_camp.domain.comment.dto.delete; + +import java.time.LocalDateTime; + +// -- D -- +public record CommentDeleteRes( + Long commentId, + LocalDateTime deletedAt +) { + public static CommentDeleteRes of(Long commentId, LocalDateTime deletedAt) { + return new CommentDeleteRes(commentId, deletedAt); + } +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/read/CommentDetailRes.java b/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/read/CommentDetailRes.java new file mode 100644 index 0000000..f48be99 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/read/CommentDetailRes.java @@ -0,0 +1,31 @@ +package springboot.kakao_boot_camp.domain.comment.dto.read; + +import springboot.kakao_boot_camp.domain.comment.entity.Comment; + +import java.time.LocalDateTime; + +public record CommentDetailRes( + Long commentId, + Long parentId, + Long postId, + Long userId, + String nickname, + String profileImageUrl, + String content, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static CommentDetailRes of(Comment comment) { + return new CommentDetailRes( + comment.getId(), + comment.getParent() != null ? comment.getParent().getId() : null, + comment.getPost().getId(), + comment.getUser().getId(), + comment.getUser().getNickName(), + comment.getUser().getProfileImage(), + comment.getContent(), + comment.getCreatedAt(), + comment.getUpdatedAt() + ); + } +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/read/CommentListRes.java b/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/read/CommentListRes.java new file mode 100644 index 0000000..e44b45a --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/read/CommentListRes.java @@ -0,0 +1,39 @@ +package springboot.kakao_boot_camp.domain.comment.dto.read; + +import springboot.kakao_boot_camp.global.dto.PageInfo; + +import java.time.LocalDateTime; +import java.util.List; + +// -- R -- +public record CommentListRes( + List comments, + PageInfo pageInfo +) { + public static CommentListRes of(List comments, PageInfo pageInfo) { + return new CommentListRes(comments, pageInfo); + } + + // 📝 댓글 요약 DTO + public record CommentSummary( + Long commentId, + Long parentId, + String nickname, + String profileImageUrl, + String content, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + public static CommentSummary of( + Long commentId, + Long parentId, + String nickname, + String profileImageUrl, + String content, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + return new CommentSummary(commentId, parentId, nickname, profileImageUrl, content, createdAt, updatedAt); + } + } +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/update/CommentUpdateReq.java b/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/update/CommentUpdateReq.java new file mode 100644 index 0000000..164f27b --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/update/CommentUpdateReq.java @@ -0,0 +1,7 @@ +package springboot.kakao_boot_camp.domain.comment.dto.update; + +// -- U -- +public record CommentUpdateReq( + String content +) { +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/update/CommentUpdateRes.java b/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/update/CommentUpdateRes.java new file mode 100644 index 0000000..4a9445c --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/update/CommentUpdateRes.java @@ -0,0 +1,25 @@ +package springboot.kakao_boot_camp.domain.comment.dto.update; + +import springboot.kakao_boot_camp.domain.comment.entity.Comment; + +import java.time.LocalDateTime; + +public record CommentUpdateRes( + Long commentId, + Long parentId, + String content, + String nickname, + String profileImage, + LocalDateTime updatedAt +) { + public static CommentUpdateRes of(Comment comment) { + return new CommentUpdateRes( + comment.getId(), + comment.getParent() != null ? comment.getParent().getId() : null, + comment.getContent(), + comment.getUser().getNickName(), + comment.getUser().getProfileImage(), + comment.getUpdatedAt() + ); + } +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/Comment.java b/src/main/java/springboot/kakao_boot_camp/domain/comment/entity/Comment.java similarity index 62% rename from src/main/java/springboot/kakao_boot_camp/domain/comment/dto/Comment.java rename to src/main/java/springboot/kakao_boot_camp/domain/comment/entity/Comment.java index 29aa5df..fb983f6 100644 --- a/src/main/java/springboot/kakao_boot_camp/domain/comment/dto/Comment.java +++ b/src/main/java/springboot/kakao_boot_camp/domain/comment/entity/Comment.java @@ -1,11 +1,10 @@ -package springboot.kakao_boot_camp.domain.comment.dto; +package springboot.kakao_boot_camp.domain.comment.entity; import jakarta.persistence.*; import jakarta.validation.constraints.Size; -import lombok.Getter; -import lombok.Setter; -import springboot.kakao_boot_camp.domain.user.entity.User; +import lombok.*; import springboot.kakao_boot_camp.domain.post.entity.Post; +import springboot.kakao_boot_camp.domain.user.model.User; import java.time.LocalDateTime; @@ -13,6 +12,8 @@ @Getter @Setter @Table(name = "comment") +@Builder +@AllArgsConstructor public class Comment { @Id @@ -20,31 +21,29 @@ public class Comment { @Column(name = "comment_id") private Long id; + @ManyToOne + @JoinColumn(name = "parent_comment_id") + private Comment parent; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "post_id") private Post post; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") + @JoinColumn(name = "user_id", nullable = false) private User user; - - // Todo - // [Content] - // size <= 1000 @Size(max = 1000) private String content; - - - // Todo - // [createtime] + @Column(name = "created_at") private LocalDateTime createdAt; - - // Todo - // [updatedTime] + @Column(name = "updated_at") private LocalDateTime updatedAt; + + public Comment() { + } } + diff --git a/src/main/java/springboot/kakao_boot_camp/domain/comment/exception/CommentNotFoundException.java b/src/main/java/springboot/kakao_boot_camp/domain/comment/exception/CommentNotFoundException.java new file mode 100644 index 0000000..99632f8 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/comment/exception/CommentNotFoundException.java @@ -0,0 +1,11 @@ +package springboot.kakao_boot_camp.domain.comment.exception; + +import springboot.kakao_boot_camp.global.api.ErrorCode; +import springboot.kakao_boot_camp.global.exception.BusinessException; + +public class CommentNotFoundException extends BusinessException { + public CommentNotFoundException(){ + super(ErrorCode.COMMENT_NOT_FOUND); + } + +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/comment/repository/CommentRepository.java b/src/main/java/springboot/kakao_boot_camp/domain/comment/repository/CommentRepository.java new file mode 100644 index 0000000..88b2ec6 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/comment/repository/CommentRepository.java @@ -0,0 +1,12 @@ +package springboot.kakao_boot_camp.domain.comment.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import springboot.kakao_boot_camp.domain.comment.entity.Comment; + +@Repository +public interface CommentRepository extends JpaRepository { + Page findByPostId(Long postId, Pageable pageable); +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/comment/service/CommentService.java b/src/main/java/springboot/kakao_boot_camp/domain/comment/service/CommentService.java index fba4f6d..a9dc928 100644 --- a/src/main/java/springboot/kakao_boot_camp/domain/comment/service/CommentService.java +++ b/src/main/java/springboot/kakao_boot_camp/domain/comment/service/CommentService.java @@ -1,4 +1,129 @@ package springboot.kakao_boot_camp.domain.comment.service; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import springboot.kakao_boot_camp.domain.comment.dto.create.CommentCreateReq; +import springboot.kakao_boot_camp.domain.comment.dto.create.CommentCreateRes; +import springboot.kakao_boot_camp.domain.comment.dto.delete.CommentDeleteRes; +import springboot.kakao_boot_camp.domain.comment.dto.read.CommentDetailRes; +import springboot.kakao_boot_camp.domain.comment.dto.read.CommentListRes; +import springboot.kakao_boot_camp.domain.comment.dto.update.CommentUpdateReq; +import springboot.kakao_boot_camp.domain.comment.dto.update.CommentUpdateRes; +import springboot.kakao_boot_camp.domain.comment.entity.Comment; +import springboot.kakao_boot_camp.domain.comment.exception.CommentNotFoundException; +import springboot.kakao_boot_camp.domain.comment.repository.CommentRepository; +import springboot.kakao_boot_camp.domain.post.entity.Post; +import springboot.kakao_boot_camp.domain.post.exception.PostNotFoundException; +import springboot.kakao_boot_camp.domain.post.repository.base.PostRepository; +import springboot.kakao_boot_camp.domain.user.model.User; +import springboot.kakao_boot_camp.domain.user.exception.UserNotFoundException; +import springboot.kakao_boot_camp.domain.user.repository.UserRepository; +import springboot.kakao_boot_camp.global.dto.PageInfo; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor public class CommentService { + private final UserRepository userRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + + + // -- C -- + public CommentCreateRes createComment(Long userId, Long postId, CommentCreateReq req) { + + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + Post post = postRepository.findById(postId) + .orElseThrow(PostNotFoundException::new); + + Comment parent = null; + + if (req.parentId() != null) { + parent = commentRepository.findById(req.parentId()) + .orElseThrow(CommentNotFoundException::new); + } + + Comment comment = Comment.builder() + .user(user) + .parent(parent) + .post(post) + .content(req.content()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + Comment saved = commentRepository.save(comment); + + return CommentCreateRes.from(saved); + } + + + // -- R -- + @Transactional(readOnly = true) + public CommentListRes getCommentList(Long postId, int page, int size) { + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + Page commentPage = commentRepository.findByPostId(postId, pageable); + + List commentList = commentPage.getContent().stream() + .map(comment -> CommentListRes.CommentSummary.of( + comment.getId(), + comment.getParent() != null ? comment.getParent().getId() : null, + comment.getUser().getNickName(), + comment.getUser().getProfileImage(), + comment.getContent(), + comment.getCreatedAt(), + comment.getUpdatedAt() + )) + .toList(); + + PageInfo pageInfo = PageInfo.of(commentPage); + + return CommentListRes.of(commentList, pageInfo); + } + + @Transactional(readOnly = true) + public CommentDetailRes getCommentDetail(Long commentId) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(CommentNotFoundException::new); + + return CommentDetailRes.of(comment); + } + + + // -- U -- + @Transactional + public CommentUpdateRes updateComment(Long commentId, CommentUpdateReq req) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(CommentNotFoundException::new); + + if (req.content() != null && !req.content().isBlank()) { + comment.setContent(req.content()); + comment.setUpdatedAt(LocalDateTime.now()); + } + + return CommentUpdateRes.of(comment); + } + + + // -- D -- + @Transactional + public CommentDeleteRes deleteComment(Long commentId) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(CommentNotFoundException::new); + + commentRepository.delete(comment); + + return CommentDeleteRes.of(commentId, LocalDateTime.now()); + } + + } diff --git a/src/main/java/springboot/kakao_boot_camp/domain/post/controller/PostController.java b/src/main/java/springboot/kakao_boot_camp/domain/post/controller/base/PostController.java similarity index 65% rename from src/main/java/springboot/kakao_boot_camp/domain/post/controller/PostController.java rename to src/main/java/springboot/kakao_boot_camp/domain/post/controller/base/PostController.java index 4735e6d..36ee45e 100644 --- a/src/main/java/springboot/kakao_boot_camp/domain/post/controller/PostController.java +++ b/src/main/java/springboot/kakao_boot_camp/domain/post/controller/base/PostController.java @@ -1,14 +1,16 @@ -package springboot.kakao_boot_camp.domain.post.controller; +package springboot.kakao_boot_camp.domain.post.controller.base; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import springboot.kakao_boot_camp.domain.post.Service.PostService; -import springboot.kakao_boot_camp.domain.post.dto.PostDtos.*; +import springboot.kakao_boot_camp.domain.post.service.base.PostService; +import springboot.kakao_boot_camp.domain.post.dto.base.PostDtos.*; import springboot.kakao_boot_camp.global.api.ApiResponse; import springboot.kakao_boot_camp.global.api.SuccessCode; +import springboot.kakao_boot_camp.security.CustomUserDetails; @RestController @RequestMapping("/api/v1/posts") @@ -17,10 +19,17 @@ public class PostController { private final PostService postService; + /* + if(auth==null){ + throw new JwtTokenExpiredException(); + } + + */ + // -- C -- @PostMapping - public ResponseEntity> create(@RequestBody @Valid PostCreateReq req) { - PostCreateRes res = postService.createPost(req); + public ResponseEntity> create(@RequestBody @Valid PostCreateReq req, @AuthenticationPrincipal CustomUserDetails currentUser) { + PostCreateRes res = postService.createPost(currentUser.getId(),req); return ResponseEntity.status(HttpStatus.CREATED) .body(ApiResponse.success(SuccessCode.POST_CREATE_SUCCESS, res)); @@ -43,9 +52,10 @@ public ResponseEntity> getDetails(@PathVariable Long // -- U -- + @PatchMapping("/{postId}") - public ResponseEntity> patch(@PathVariable Long postId, /*@AuthenticationPrincipal UserDetails user,*/ @RequestBody PostUpdateReq req) { - PostUpdateRes res = postService.updatePost(postId, req); + public ResponseEntity> patch(@PathVariable Long postId, @AuthenticationPrincipal CustomUserDetails currentUser, @RequestBody PostUpdateReq req) { + PostUpdateRes res = postService.updatePost(currentUser.getId(), postId, req); return ResponseEntity.status(HttpStatus.OK) .body(ApiResponse.success(SuccessCode.POST_UPDATE_SUCCESS, res)); @@ -54,8 +64,8 @@ public ResponseEntity> patch(@PathVariable Long postI // -- D -- @DeleteMapping("/{postId}") - public ResponseEntity> delete(@PathVariable Long postId) { - PostDeleteRes res = postService.deletePost(postId); + public ResponseEntity> delete(@PathVariable Long postId, @AuthenticationPrincipal CustomUserDetails currentUser) { + PostDeleteRes res = postService.deletePost(currentUser.getId(), postId); return ResponseEntity.status(HttpStatus.OK) .body(ApiResponse.success(SuccessCode.POST_DELETE_SUCCESS, res)); diff --git a/src/main/java/springboot/kakao_boot_camp/domain/post/controller/like/PostLikeController.java b/src/main/java/springboot/kakao_boot_camp/domain/post/controller/like/PostLikeController.java new file mode 100644 index 0000000..363b708 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/post/controller/like/PostLikeController.java @@ -0,0 +1,43 @@ +package springboot.kakao_boot_camp.domain.post.controller.like; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import springboot.kakao_boot_camp.domain.post.dto.base.PostDtos; +import springboot.kakao_boot_camp.domain.post.enums.PostSuccessCode; +import springboot.kakao_boot_camp.domain.post.service.like.PostLikeService; +import springboot.kakao_boot_camp.global.api.ApiResponse; +import springboot.kakao_boot_camp.global.api.SuccessCode; +import springboot.kakao_boot_camp.security.CustomUserDetails; + +@RestController +@RequestMapping("/api/v1/like/posts") +@RequiredArgsConstructor +public class PostLikeController { + + private final PostLikeService postLikeService; + + // -- R -- + @GetMapping("/{postId}") + public ResponseEntity> likeOrUnlikePost(@AuthenticationPrincipal CustomUserDetails currentUser + ,@ PathVariable Long postId) { + + boolean liked = postLikeService.postLikeOrUnlike(currentUser.getId(), postId); + + if (liked) { + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(PostSuccessCode.POST_LIKE_SUCCESS, null)); + } else { + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(PostSuccessCode.POST_DISLIKE_SUCCESS, null)); + } + } + + + +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/post/dto/PostDtos.java b/src/main/java/springboot/kakao_boot_camp/domain/post/dto/base/PostDtos.java similarity index 84% rename from src/main/java/springboot/kakao_boot_camp/domain/post/dto/PostDtos.java rename to src/main/java/springboot/kakao_boot_camp/domain/post/dto/base/PostDtos.java index 89710c8..8525d47 100644 --- a/src/main/java/springboot/kakao_boot_camp/domain/post/dto/PostDtos.java +++ b/src/main/java/springboot/kakao_boot_camp/domain/post/dto/base/PostDtos.java @@ -1,16 +1,15 @@ -package springboot.kakao_boot_camp.domain.post.dto; +package springboot.kakao_boot_camp.domain.post.dto.base; -import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import springboot.kakao_boot_camp.domain.post.entity.Post; +import springboot.kakao_boot_camp.global.dto.CursorInfo; import java.time.LocalDateTime; -import java.util.HashMap; import java.util.List; public class PostDtos { - // -- C == + // -- 게시글 생성 == public record PostCreateReq( @NotBlank(message = "제목이 비었습니다.") String title, @@ -34,18 +33,18 @@ public static PostCreateRes from(Post post){ post.getTitle(), post.getContent(), post.getImageUrl(), - post.getCratedAt() + post.getCreatedAt() ); } } - // -- R -- + // -- 게시글 리스트 및 단건 조회 -- public record PostListRes( List posts, - PageInfo pageInfo + CursorInfo pageInfo ) { - public static PostListRes of(List posts, PageInfo pageInfo) { + public static PostListRes of(List posts, CursorInfo pageInfo) { return new PostListRes(posts, pageInfo); } @@ -55,9 +54,11 @@ public record PostSummary( String title, String nickname, String profileImageUrl, + int likeCount, int commentCount, int viewCount, + LocalDateTime createdAt, LocalDateTime updatedAt ) { @@ -86,16 +87,7 @@ public static PostSummary of( } } - // 🧭 페이지 정보 - public record PageInfo( - boolean hasNext, - Long nextCursor, - int size - ) { - public static PageInfo of(boolean hasNext, Long nextCursor, int size) { - return new PageInfo(hasNext, nextCursor, size); - } - } + } public record PostDetailRes( @@ -120,7 +112,7 @@ public static PostDetailRes from(Post post){ post.getLikeCount(), post.getViewCount(), post.getCommentCount(), - post.getCratedAt(), + post.getCreatedAt(), post.getUpdatedAt() ); } @@ -128,7 +120,7 @@ public static PostDetailRes from(Post post){ } - // -- U -- + // -- 게시글 수정 -- public record PostUpdateReq( String title, String content, @@ -156,17 +148,17 @@ public static PostUpdateRes from(Post post){ post.getLikeCount(), post.getViewCount(), post.getCommentCount(), - post.getCratedAt(), + post.getCreatedAt(), post.getUpdatedAt() ); } } - // -- D -- + // -- 게시글 삭제 -- public record PostDeleteReq(){} public record PostDeleteRes( - Long id, + Long postId, // boolean deleted, // 추후 soft 삭제 시 사용 LocalDateTime deletedAt ){ @@ -175,4 +167,4 @@ public static PostDeleteRes from(Long id, /*boolean deleted,*/ LocalDateTime del } } -} +} \ No newline at end of file diff --git a/src/main/java/springboot/kakao_boot_camp/domain/post/entity/Post.java b/src/main/java/springboot/kakao_boot_camp/domain/post/entity/Post.java index dedb702..d0a89e8 100644 --- a/src/main/java/springboot/kakao_boot_camp/domain/post/entity/Post.java +++ b/src/main/java/springboot/kakao_boot_camp/domain/post/entity/Post.java @@ -4,7 +4,7 @@ import lombok.Getter; import lombok.Setter; import org.springframework.data.annotation.CreatedDate; -import springboot.kakao_boot_camp.domain.user.entity.User; +import springboot.kakao_boot_camp.domain.user.model.User; import java.time.LocalDateTime; @@ -40,7 +40,7 @@ public class Post { int commentCount = 0; @CreatedDate - LocalDateTime cratedAt; + LocalDateTime createdAt; LocalDateTime updatedAt; diff --git a/src/main/java/springboot/kakao_boot_camp/domain/post/entity/PostLike.java b/src/main/java/springboot/kakao_boot_camp/domain/post/entity/PostLike.java index c42dc34..4da74dc 100644 --- a/src/main/java/springboot/kakao_boot_camp/domain/post/entity/PostLike.java +++ b/src/main/java/springboot/kakao_boot_camp/domain/post/entity/PostLike.java @@ -1,15 +1,24 @@ package springboot.kakao_boot_camp.domain.post.entity; import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.Setter; -import springboot.kakao_boot_camp.domain.user.entity.User; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import springboot.kakao_boot_camp.domain.user.model.User; import java.time.LocalDateTime; @Entity @Getter @Setter +@Builder +@Table(name = "post_like") +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) + public class PostLike { @Id @@ -24,8 +33,14 @@ public class PostLike { @JoinColumn(name = "user_id") private User user; - @Column - LocalDateTime createdAt; + @Column(name = "created_at") + @CreatedDate + private LocalDateTime createdAt; + + public PostLike() { + + } + // 좋아요는 수정 시각이 필요가 없음 diff --git a/src/main/java/springboot/kakao_boot_camp/domain/post/enums/PostSuccessCode.java b/src/main/java/springboot/kakao_boot_camp/domain/post/enums/PostSuccessCode.java new file mode 100644 index 0000000..aec39e2 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/post/enums/PostSuccessCode.java @@ -0,0 +1,29 @@ +package springboot.kakao_boot_camp.domain.post.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; +import springboot.kakao_boot_camp.global.api.SuccessCodeInterface; + +@AllArgsConstructor +@Getter +public enum PostSuccessCode implements SuccessCodeInterface { + + // -- 1. base -- + POST_CREATE_SUCCESS(HttpStatus.CREATED, "게시글을 성공적으로 등록하였습니다."), + POST_LIST_READ_SUCCESS(HttpStatus.OK, "게시글 목록을 성공적으로 조회하였습니다."), + POST_DETAIL_READ_SUCCESS(HttpStatus.OK, "게시글 조회를 성공하였습니다."), + POST_UPDATE_SUCCESS(HttpStatus.OK, "게시글을 성공적으로 수정하였습니다."), + POST_DELETE_SUCCESS(HttpStatus.OK, "게시글을 성공적으로 삭제하였습니다."), + + + + // -- 2. like -- + POST_LIKE_SUCCESS(HttpStatus.OK, "게시글 좋아요를 성공적으로 실행하였습니다."), + POST_DISLIKE_SUCCESS(HttpStatus.OK, "게시글 좋아요 취소를 성공적으로 실행하였습니다."); + + + + private final HttpStatus status; + private final String message; + } diff --git a/src/main/java/springboot/kakao_boot_camp/domain/post/exception/AccessDeniedPostException.java b/src/main/java/springboot/kakao_boot_camp/domain/post/exception/AccessDeniedPostException.java new file mode 100644 index 0000000..9e972c3 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/post/exception/AccessDeniedPostException.java @@ -0,0 +1,10 @@ +package springboot.kakao_boot_camp.domain.post.exception; + +import springboot.kakao_boot_camp.global.api.ErrorCode; +import springboot.kakao_boot_camp.global.exception.BusinessException; + +public class AccessDeniedPostException extends BusinessException { + public AccessDeniedPostException(){ + super(ErrorCode.POST_DENIED); + } +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/post/repository/PostRepository.java b/src/main/java/springboot/kakao_boot_camp/domain/post/repository/base/PostRepository.java similarity index 89% rename from src/main/java/springboot/kakao_boot_camp/domain/post/repository/PostRepository.java rename to src/main/java/springboot/kakao_boot_camp/domain/post/repository/base/PostRepository.java index ad61ea0..0b0102e 100644 --- a/src/main/java/springboot/kakao_boot_camp/domain/post/repository/PostRepository.java +++ b/src/main/java/springboot/kakao_boot_camp/domain/post/repository/base/PostRepository.java @@ -1,4 +1,4 @@ -package springboot.kakao_boot_camp.domain.post.repository; +package springboot.kakao_boot_camp.domain.post.repository.base; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -6,7 +6,6 @@ import springboot.kakao_boot_camp.domain.post.entity.Post; import java.util.List; -import java.util.Optional; public interface PostRepository extends JpaRepository { // 첫 페이지 (cursor = 0) diff --git a/src/main/java/springboot/kakao_boot_camp/domain/post/repository/like/PostLikeRepository.java b/src/main/java/springboot/kakao_boot_camp/domain/post/repository/like/PostLikeRepository.java new file mode 100644 index 0000000..9f9116a --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/post/repository/like/PostLikeRepository.java @@ -0,0 +1,12 @@ +package springboot.kakao_boot_camp.domain.post.repository.like; + +import org.springframework.data.jpa.repository.JpaRepository; +import springboot.kakao_boot_camp.domain.post.entity.PostLike; + +import javax.swing.text.html.Option; +import java.util.Optional; + + +public interface PostLikeRepository extends JpaRepository { + Optional findByUserIdAndPostId(Long userID, Long postId); +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/post/Service/PostService.java b/src/main/java/springboot/kakao_boot_camp/domain/post/service/base/PostService.java similarity index 59% rename from src/main/java/springboot/kakao_boot_camp/domain/post/Service/PostService.java rename to src/main/java/springboot/kakao_boot_camp/domain/post/service/base/PostService.java index e3fb47b..5f09141 100644 --- a/src/main/java/springboot/kakao_boot_camp/domain/post/Service/PostService.java +++ b/src/main/java/springboot/kakao_boot_camp/domain/post/service/base/PostService.java @@ -1,38 +1,63 @@ -package springboot.kakao_boot_camp.domain.post.Service; +package springboot.kakao_boot_camp.domain.post.service.base; -import org.springframework.cglib.core.Local; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import springboot.kakao_boot_camp.domain.post.dto.PostDtos.*; +import springboot.kakao_boot_camp.domain.post.dto.base.PostDtos.*; import springboot.kakao_boot_camp.domain.post.entity.Post; +import springboot.kakao_boot_camp.domain.post.exception.AccessDeniedPostException; import springboot.kakao_boot_camp.domain.post.exception.PostNotFoundException; -import springboot.kakao_boot_camp.domain.post.repository.PostRepository; +import springboot.kakao_boot_camp.domain.post.repository.base.PostRepository; +import springboot.kakao_boot_camp.domain.user.model.User; +import springboot.kakao_boot_camp.domain.user.exception.UserNotFoundException; +import springboot.kakao_boot_camp.domain.user.repository.UserRepository; +import springboot.kakao_boot_camp.global.dto.CursorInfo; import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; @Service +@RequiredArgsConstructor public class PostService { - + private final UserRepository userRepository; private final PostRepository postRepository; - public PostService(PostRepository postRepository) { - this.postRepository = postRepository; + // -- Create Post -- + public PostCreateRes createPost(Long userId, PostCreateReq req) { + + if (userId == null) { + throw new UserNotFoundException(); + } + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + + Post post = new Post(); + + post.setTitle(req.title()); + post.setUser(user); + post.setContent(req.content()); + post.setImageUrl(req.imageUrl()); + post.setCreatedAt(LocalDateTime.now()); + post.setUpdatedAt(LocalDateTime.now()); + + Post saved = postRepository.save(post); + + return PostCreateRes.from(saved); } - // -- Get -- + // -- Get Post -- @Transactional public PostDetailRes getPostDetail(Long id) { Post post = postRepository.findById(id) .orElseThrow(PostNotFoundException::new); - return PostDetailRes.from(post); + post.setViewCount(post.getViewCount()+1); + return PostDetailRes.from(post); } - @Transactional(readOnly = true) public PostListRes getPostList(Long cursor) { int size = 10; // 한 번에 가져올 게시글 수 @@ -59,61 +84,51 @@ public PostListRes getPostList(Long cursor) { post.getLikeCount(), post.getCommentCount(), post.getViewCount(), - post.getCratedAt(), + post.getCreatedAt(), post.getUpdatedAt() )) .toList(); // 📍 페이지 정보 생성 - PostListRes.PageInfo pageInfo = PostListRes.PageInfo.of(hasNext, nextCursor, size); + CursorInfo pageInfo = CursorInfo.of(hasNext, nextCursor, size); return PostListRes.of(postSummaries, pageInfo); } - // -- Post -- - public PostCreateRes createPost(PostCreateReq req) { - Post post = new Post(); - post.setTitle(req.title()); - - // Todo : 후 인증 기능 추가하면 넣을 예정 - // post.setUser(); - - post.setContent(req.content()); - post.setImageUrl(req.imageUrl()); - post.setCratedAt(LocalDateTime.now()); - post.setUpdatedAt(LocalDateTime.now()); - - Post saved = postRepository.save(post); - - return PostCreateRes.from(post); - } - - // -- Update : Patch -- + // -- Update Post -- @Transactional - public PostUpdateRes updatePost(Long postId, PostUpdateReq req) { + public PostUpdateRes updatePost(Long userId, Long postId, PostUpdateReq req) { Post post = postRepository.findById(postId) .orElseThrow(PostNotFoundException::new); + if (!userId.equals(post.getUser().getId())) { + throw new AccessDeniedPostException(); + } + // 더티 체킹 - if (req.title() != null) post.setTitle(req.title()); - if (req.content() != null) post.setContent(req.content()); - if (req.imageUrl() != null) post.setImageUrl(req.imageUrl()); + if (req.title() != null) post.setTitle(req.title()); + if (req.content() != null) post.setContent(req.content()); + if (req.imageUrl() != null) post.setImageUrl(req.imageUrl()); return PostUpdateRes.from(post); } - // -- Delete -- + + // -- Delete Post -- @Transactional - public PostDeleteRes deletePost(Long postId) { + public PostDeleteRes deletePost(Long userId, Long postId) { Post post = postRepository.findById(postId) .orElseThrow(PostNotFoundException::new); + if (!userId.equals(post.getUser().getId())) { + throw new AccessDeniedPostException(); + } + postRepository.delete(post); // 실제 삭제 return PostDeleteRes.from(postId, LocalDateTime.now()); - } diff --git a/src/main/java/springboot/kakao_boot_camp/domain/post/service/like/PostLikeService.java b/src/main/java/springboot/kakao_boot_camp/domain/post/service/like/PostLikeService.java new file mode 100644 index 0000000..4db6dbd --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/post/service/like/PostLikeService.java @@ -0,0 +1,73 @@ +package springboot.kakao_boot_camp.domain.post.service.like; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import springboot.kakao_boot_camp.domain.post.entity.Post; +import springboot.kakao_boot_camp.domain.post.entity.PostLike; +import springboot.kakao_boot_camp.domain.post.exception.PostNotFoundException; +import springboot.kakao_boot_camp.domain.post.repository.base.PostRepository; +import springboot.kakao_boot_camp.domain.post.repository.like.PostLikeRepository; +import springboot.kakao_boot_camp.domain.user.exception.UserNotFoundException; +import springboot.kakao_boot_camp.domain.user.model.User; +import springboot.kakao_boot_camp.domain.user.repository.UserRepository; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class PostLikeService { + private final UserRepository userRepository; + private final PostRepository postRepository; + private final PostLikeRepository postLikeRepository; + + + // 1. 포스트 좋아요 + @Transactional + public boolean postLikeOrUnlike(Long userId, Long postId) { + + // 1. 포스트 아이디 얻기 + Post post = postRepository.findById(postId) + .orElseThrow(PostNotFoundException::new); + // 2. 유저 아이디 얻기 + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + + + // 3. 해당 유저가 이미 좋아요를 눌렀는지 체크 + // 3.1 유저 좋아요 정보 가져오기 + Optional opt = postLikeRepository.findByUserIdAndPostId(userId, postId); + + // 3.2 좋아요 정보가 없으면 생성 후 해당 게시글 좋아요 카운트 +1 + if (opt.isEmpty()) { + PostLike postLike = PostLike.builder() + .user(user) + .post(post) + .build(); + + postLikeRepository.save(postLike); + + post.setLikeCount(post.getLikeCount() + 1); + + + return true; + } + + // 3.2 좋아요 정보가 있으면 삭제 후 해당 게시글 좋아요 카운트 -1 + else { + postLikeRepository.delete(opt.get()); + + if (post.getLikeCount() >= 0) { + post.setLikeCount(post.getLikeCount() - 1); + } + + return false; + } + + + } + + +} + + diff --git a/src/main/java/springboot/kakao_boot_camp/domain/user/controller/UserController.java b/src/main/java/springboot/kakao_boot_camp/domain/user/controller/UserController.java deleted file mode 100644 index 0fd200f..0000000 --- a/src/main/java/springboot/kakao_boot_camp/domain/user/controller/UserController.java +++ /dev/null @@ -1,30 +0,0 @@ -package springboot.kakao_boot_camp.domain.user.controller; - - -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; -import springboot.kakao_boot_camp.domain.user.dto.UserResDto; -import springboot.kakao_boot_camp.domain.user.entity.User; -import springboot.kakao_boot_camp.domain.user.service.UserService; -import springboot.kakao_boot_camp.global.api.ApiResponse; -import springboot.kakao_boot_camp.global.api.SuccessCode; - -import java.util.List; - -@RestController -@RequiredArgsConstructor -public class UserController { - - private final UserService userService; - - @GetMapping("/users") - public ResponseEntity> getUsers(){ - List users =userService.getUsers(); - return ResponseEntity - .status(HttpStatus.OK) - .body(ApiResponse.success(SuccessCode.GET_USERS_SUCCESS, new UserResDto(users))); - } -} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/user/dto/UserResDto.java b/src/main/java/springboot/kakao_boot_camp/domain/user/dto/UserResDto.java deleted file mode 100644 index de45ff4..0000000 --- a/src/main/java/springboot/kakao_boot_camp/domain/user/dto/UserResDto.java +++ /dev/null @@ -1,15 +0,0 @@ -package springboot.kakao_boot_camp.domain.user.dto; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.Setter; -import springboot.kakao_boot_camp.domain.user.entity.User; - -import java.util.List; - -@Getter -@Setter -@AllArgsConstructor -public class UserResDto { - private List users; -} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/user/entity/User.java b/src/main/java/springboot/kakao_boot_camp/domain/user/entity/User.java deleted file mode 100644 index 713ddce..0000000 --- a/src/main/java/springboot/kakao_boot_camp/domain/user/entity/User.java +++ /dev/null @@ -1,59 +0,0 @@ -package springboot.kakao_boot_camp.domain.user.entity; - -import jakarta.persistence.*; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; -import lombok.*; -import org.springframework.data.annotation.CreatedDate; -import springboot.kakao_boot_camp.domain.post.entity.Post; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - -@Entity -@Getter -@Builder -@AllArgsConstructor -@NoArgsConstructor -public class User { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "user_id") - private Long Id; - - @Column(nullable = false) - @Email(message = "올바른 이메일 형식을 입력해주세요.") - private String email; - - @Column(nullable = false) - private String passWord; - - @Column(nullable = false) - @Size(min = 2, max = 10, message = "닉네임은 2~10자여야 합니다.") - private String nickName; - - @Column(nullable = false) - @NotBlank(message = "프로필 사진을 추가해주세요.") - private String profileImage; - - - @OneToMany(mappedBy = "user",fetch = FetchType.LAZY) - @Builder.Default - private List posts = new ArrayList<>(); // 명시적인 형태 - - LocalDateTime cratedAt; - - LocalDateTime updatedAt; - - LocalDateTime deletedAt; - - - - - - - -} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/user/exception/UserNotFoundException.java b/src/main/java/springboot/kakao_boot_camp/domain/user/exception/UserNotFoundException.java new file mode 100644 index 0000000..488d909 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/domain/user/exception/UserNotFoundException.java @@ -0,0 +1,11 @@ +package springboot.kakao_boot_camp.domain.user.exception; + +import springboot.kakao_boot_camp.global.api.ErrorCode; +import springboot.kakao_boot_camp.global.exception.BusinessException; + +public class UserNotFoundException extends BusinessException { + public UserNotFoundException(){ + super(ErrorCode.User_NOT_FOUND); + } + +} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/user/repository/UserRepoCustom.java b/src/main/java/springboot/kakao_boot_camp/domain/user/repository/UserRepoCustom.java deleted file mode 100644 index 6325665..0000000 --- a/src/main/java/springboot/kakao_boot_camp/domain/user/repository/UserRepoCustom.java +++ /dev/null @@ -1,11 +0,0 @@ -package springboot.kakao_boot_camp.domain.user.repository; - -import org.springframework.stereotype.Repository; -import springboot.kakao_boot_camp.domain.user.entity.User; - -import java.util.List; - -@Repository -public interface UserRepoCustom { - public List getUsers(); -} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/user/repository/UserRepoCustomImpl.java b/src/main/java/springboot/kakao_boot_camp/domain/user/repository/UserRepoCustomImpl.java deleted file mode 100644 index 2d47456..0000000 --- a/src/main/java/springboot/kakao_boot_camp/domain/user/repository/UserRepoCustomImpl.java +++ /dev/null @@ -1,27 +0,0 @@ -package springboot.kakao_boot_camp.domain.user.repository; - - -import jakarta.persistence.EntityManager; -import jakarta.persistence.TypedQuery; -import lombok.RequiredArgsConstructor; -import springboot.kakao_boot_camp.domain.user.entity.*; - -import java.util.List; - - - -@RequiredArgsConstructor -public class UserRepoCustomImpl implements UserRepoCustom { - - final private EntityManager entityManager; - - public List getUsers(){ - TypedQuery query = entityManager.createQuery( - "select u from User u", User.class - ); - - return query.getResultList(); - } - - -} diff --git a/src/main/java/springboot/kakao_boot_camp/domain/user/repository/UserRepo.java b/src/main/java/springboot/kakao_boot_camp/domain/user/repository/UserRepository.java similarity index 69% rename from src/main/java/springboot/kakao_boot_camp/domain/user/repository/UserRepo.java rename to src/main/java/springboot/kakao_boot_camp/domain/user/repository/UserRepository.java index 716a1cf..b5864b6 100644 --- a/src/main/java/springboot/kakao_boot_camp/domain/user/repository/UserRepo.java +++ b/src/main/java/springboot/kakao_boot_camp/domain/user/repository/UserRepository.java @@ -2,13 +2,13 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import springboot.kakao_boot_camp.domain.user.entity.User; +import springboot.kakao_boot_camp.domain.user.model.User; import java.util.Optional; @Repository -public interface UserRepo extends JpaRepository, UserRepoCustom { +public interface UserRepository extends JpaRepository { Optional findByEmail(String email); boolean existsByEmail(String email); } diff --git a/src/main/java/springboot/kakao_boot_camp/domain/user/service/UserService.java b/src/main/java/springboot/kakao_boot_camp/domain/user/service/UserService.java deleted file mode 100644 index 9d04e57..0000000 --- a/src/main/java/springboot/kakao_boot_camp/domain/user/service/UserService.java +++ /dev/null @@ -1,19 +0,0 @@ -package springboot.kakao_boot_camp.domain.user.service; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import springboot.kakao_boot_camp.domain.user.entity.User; -import springboot.kakao_boot_camp.domain.user.repository.UserRepo; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class UserService { - - final private UserRepo postRepo; - - public List getUsers() { - return postRepo.getUsers(); - } -} diff --git a/src/main/java/springboot/kakao_boot_camp/global/api/ApiResponse.java b/src/main/java/springboot/kakao_boot_camp/global/api/ApiResponse.java index f3b0c11..dfcc19c 100644 --- a/src/main/java/springboot/kakao_boot_camp/global/api/ApiResponse.java +++ b/src/main/java/springboot/kakao_boot_camp/global/api/ApiResponse.java @@ -3,6 +3,7 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Setter; +import springboot.kakao_boot_camp.domain.post.enums.PostSuccessCode; @Getter @@ -19,6 +20,10 @@ public static ApiResponse success(SuccessCode successMessage, T data){ return new ApiResponse(successMessage.getStatus().value(), successMessage.getMessage(), data); } + public static ApiResponse success(SuccessCodeInterface successMessage, T data){ // 를 반환 타입 앞에 써주는 이유 : 컴파일시 해당 메서드의 반환 타입을 알기 위해서, 런타임시 파라미터로 들어온 T를 static 상황(컴파일 상황)에서는 모르기 때문이다. 컴퓨일이 런타임 이전에 일어나기 때문에 + return new ApiResponse(successMessage.getStatus().value(), successMessage.getMessage(), data); + } + // -- Error -- public static ApiResponse error(ErrorCode errorCode) { return new ApiResponse( @@ -27,4 +32,6 @@ public static ApiResponse error(ErrorCode errorCode) { null ); } + + } diff --git a/src/main/java/springboot/kakao_boot_camp/global/api/ErrorCode.java b/src/main/java/springboot/kakao_boot_camp/global/api/ErrorCode.java index 69136f0..b2fffa3 100644 --- a/src/main/java/springboot/kakao_boot_camp/global/api/ErrorCode.java +++ b/src/main/java/springboot/kakao_boot_camp/global/api/ErrorCode.java @@ -4,20 +4,41 @@ import lombok.Getter; import org.springframework.http.HttpStatus; + +// Todo 1. 추후 성공과 실패 코드가 아닌 도메인별로 나눌 필요 @AllArgsConstructor @Getter public enum ErrorCode { // -- Auth - INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), // 400 회원 가입 + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청 인자입니다."), // 400 회원 가입 DUPLICATE_EMAIL(HttpStatus.CONFLICT, "중복된 이메일 입니다."), // 409 회원 가입 - AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 잘 못 되었습니다.") , // 401 로그인 + AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 잘 못 되었습니다."), // 401 로그인 + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."), - // -- User -- + // -- JWT -- + TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "로그인 토큰이 만료되었습니다. 다시 로그인해주세요."), + INVALID_TOKEN_TYPE(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰 형식입니다. 다시 로그인해주세요."), + TOKEN_LOGOUTED(HttpStatus.UNAUTHORIZED, "이미 로그아웃된 토큰입니다."), + + // -- SESSION -- + SESSION_EXPIRED(HttpStatus.UNAUTHORIZED, "세션이 만료되었습니다. 다시 로그인해주세요."), + + // -- User -- + User_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 유저를 찾을 수 없습니다."), //404 + NOT_HAVE_ADMIN_ROLE(HttpStatus.BAD_REQUEST, "관리자 권한이 없는 유저입니다."), + ALREADY_DELETE_USR(HttpStatus.CONFLICT, "이미 삭제한 유저입니다."), //409, 이미 삭제한 유저기때문에 conflict 충돌이라는 의미 + PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "변경할 비밀번호와 확인 비밀번호가 일치하지 않습니다."), //409, 이미 삭제한 유저기때문에 conflict 충돌이라는 의미 + WRONG_CURRENT_PASSWORD(HttpStatus.UNAUTHORIZED, "현재 비밀번호가 올바르지 않습니다."), //409, 이미 삭제한 유저기때문에 conflict 충돌이라는 의미 // -- Post -- - POST_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 게시글을 찾을 수 없습니다."); //404 + POST_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 게시글을 찾을 수 없습니다."), //404 + POST_DENIED(HttpStatus.FORBIDDEN, "본인의 게시글만 수정/삭제할 수 있습니다."), //404 + + + // -- Comment -- + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 댓글 찾을 수 없습니다."); //404 private final HttpStatus status; diff --git a/src/main/java/springboot/kakao_boot_camp/global/api/SuccessCode.java b/src/main/java/springboot/kakao_boot_camp/global/api/SuccessCode.java index 1bb9ce1..9987c3b 100644 --- a/src/main/java/springboot/kakao_boot_camp/global/api/SuccessCode.java +++ b/src/main/java/springboot/kakao_boot_camp/global/api/SuccessCode.java @@ -10,21 +10,36 @@ public enum SuccessCode { // -- Auth -- - REGISTER_SUCCESS(HttpStatus.OK, "회원가입을 성공하였습니다."), + REGISTER_SUCCESS(HttpStatus.CREATED, "회원가입을 성공하였습니다."), LOGIN_SUCCESS(HttpStatus.OK, "로그인에 성공했습니다"), + LOGOUT_SUCCESS(HttpStatus.OK, "로그아웃에 성공했습니다"), + TOKEN_REFERSH_SUCCESS(HttpStatus.OK, "액세스 토큰 재발급에 성공하였습니다."), + // -- User -- + CREATE_USER_SUCCESS(HttpStatus.CREATED, "사용자 생성을 성공하였습니다."), GET_USERS_SUCCESS(HttpStatus.OK, "사용자 목록 조회를 성공하였습니다."), + GET_MY_PROFILE_SUCCESS(HttpStatus.OK, "사용자 프로필 조회를 성공하였습니다."), + UPDATE_MY_PROFILE_SUCCESS(HttpStatus.OK, "사용자 프로필 수정을 성공하였습니다."), + SOFT_DELETE_MY_PROFILE_SUCCESS(HttpStatus.OK, "사용자 임시 삭제를 성공하였습니다."), + HARD_DELETE_MY_PROFILE_SUCCESS(HttpStatus.OK, "사용자 완전 삭제를 성공하였습니다."), + PASSWORD_CHANGE_SUCCESS(HttpStatus.OK, "비밀번호 변경을 성공하였습니다."), - // -- Post -- - POST_DETAIL_READ_SUCCESS(HttpStatus.OK, "게시글 조회를 성공하였습니다."), - POST_LIST_READ_SUCCESS(HttpStatus.OK, "게시글 목록을 성공적으로 조회하였습니다."), + // -- Post -- POST_CREATE_SUCCESS(HttpStatus.CREATED, "게시글을 성공적으로 등록하였습니다."), - + POST_LIST_READ_SUCCESS(HttpStatus.OK, "게시글 목록을 성공적으로 조회하였습니다."), + POST_DETAIL_READ_SUCCESS(HttpStatus.OK, "게시글 조회를 성공하였습니다."), POST_UPDATE_SUCCESS(HttpStatus.OK, "게시글을 성공적으로 수정하였습니다."), - - POST_DELETE_SUCCESS(HttpStatus.OK, "게시글을 성공적으로 삭제하였습니다."); + POST_DELETE_SUCCESS(HttpStatus.OK, "게시글을 성공적으로 삭제하였습니다."), + + // -- Comment -- + COMMENT_CREATE_SUCCESS(HttpStatus.CREATED, "댓글을 성공적으로 등록하였습니다."), + COMMENT_LIST_READ_SUCCESS(HttpStatus.OK, "댓글 목록을 성공적으로 조회하였습니다."), + COMMENT_LIST_EMPTY(HttpStatus.OK, "댓글이 없습니다."), + COMMENT_READ_SUCCESS(HttpStatus.OK, "댓글을 성공적으로 조회하였습니다."), + COMMENT_UPDATE_SUCCESS(HttpStatus.OK, "댓글을 성공적으로 수정하였습니다."), + COMMENT_DELETE_SUCCESS(HttpStatus.OK, "댓글을 성공적으로 삭제하였습니다."); private final HttpStatus status; diff --git a/src/main/java/springboot/kakao_boot_camp/global/api/SuccessCodeInterface.java b/src/main/java/springboot/kakao_boot_camp/global/api/SuccessCodeInterface.java new file mode 100644 index 0000000..87336b8 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/global/api/SuccessCodeInterface.java @@ -0,0 +1,9 @@ +package springboot.kakao_boot_camp.global.api; + +import org.springframework.http.HttpStatus; + +public interface SuccessCodeInterface { + public HttpStatus getStatus(); + public String getMessage(); + +} diff --git a/src/main/java/springboot/kakao_boot_camp/global/config/SecurityConfig.java b/src/main/java/springboot/kakao_boot_camp/global/config/SecurityConfig.java deleted file mode 100644 index bc067e3..0000000 --- a/src/main/java/springboot/kakao_boot_camp/global/config/SecurityConfig.java +++ /dev/null @@ -1,15 +0,0 @@ -package springboot.kakao_boot_camp.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; - -@Configuration -public class SecurityConfig { - - @Bean - public PasswordEncoder passwordEncoder(){ - return new BCryptPasswordEncoder(); - } -} diff --git a/src/main/java/springboot/kakao_boot_camp/global/config/ValidationConfig.java b/src/main/java/springboot/kakao_boot_camp/global/config/ValidationConfig.java new file mode 100644 index 0000000..50b3e68 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/global/config/ValidationConfig.java @@ -0,0 +1,15 @@ +package springboot.kakao_boot_camp.global.config; + + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@Configuration +public class ValidationConfig { + + @Bean + public LocalValidatorFactoryBean validator() { + return new LocalValidatorFactoryBean(); + } +} \ No newline at end of file diff --git a/src/main/java/springboot/kakao_boot_camp/global/config/jwt/JwtConfig.java b/src/main/java/springboot/kakao_boot_camp/global/config/jwt/JwtConfig.java new file mode 100644 index 0000000..5dfc0a4 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/global/config/jwt/JwtConfig.java @@ -0,0 +1,40 @@ +package springboot.kakao_boot_camp.global.config.jwt; + +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +import javax.crypto.SecretKey; + +@Configuration +@Getter +public class JwtConfig { + + @Value("${jwt.access.secret}") + private static String accessSecret; + @Value("${jwt.access.expiration}") + private long accessSecretExp; + + @Value("${jwt.refresh.secret}") + private String refreshSecret; + @Value("${jwt.refresh.expiration}") + private long refreshSecretExp; + + + SecretKey getSecretKey(String secret) { + return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); + } + + public SecretKey getAccessSecretKey() { + return getSecretKey(this.accessSecret); + } + + public SecretKey getRefreshSecretKey() { + return getSecretKey(this.refreshSecret); + } + + + +} diff --git a/src/main/java/springboot/kakao_boot_camp/global/config/redis/RedisSessionConfig.java b/src/main/java/springboot/kakao_boot_camp/global/config/redis/RedisSessionConfig.java new file mode 100644 index 0000000..8cdcdc6 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/global/config/redis/RedisSessionConfig.java @@ -0,0 +1,30 @@ +package springboot.kakao_boot_camp.global.config.redis; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisPassword; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession; + +@Configuration +@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600) +public class RedisSessionConfig { + + @Bean + public RedisSerializer springSessionDefaultRedisSerializer() { + return new JdkSerializationRedisSerializer(); + } + + @Bean + public LettuceConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); + config.setHostName("127.0.0.1"); + config.setPort(6379); + config.setPassword(RedisPassword.of("gustjq3735!")); // 여기가 핵심 + return new LettuceConnectionFactory(config); + } +} diff --git a/src/main/java/springboot/kakao_boot_camp/global/constant/session/SessionConst.java b/src/main/java/springboot/kakao_boot_camp/global/constant/session/SessionConst.java new file mode 100644 index 0000000..67675bd --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/global/constant/session/SessionConst.java @@ -0,0 +1,7 @@ +package springboot.kakao_boot_camp.global.constant.session; + +public class SessionConst { + public static final String USER_ID_KEY = "userId"; + public static final String EMAIL_KEY = "email"; + public static final String ROLE_KEY = "role"; +} diff --git a/src/main/java/springboot/kakao_boot_camp/global/dto/CursorInfo.java b/src/main/java/springboot/kakao_boot_camp/global/dto/CursorInfo.java new file mode 100644 index 0000000..c66fa81 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/global/dto/CursorInfo.java @@ -0,0 +1,11 @@ +package springboot.kakao_boot_camp.global.dto; + +public record CursorInfo( + boolean hasNext, + Long nextCursor, + int size +) { + public static CursorInfo of(boolean hasNext, Long nextCursor, int size) { + return new CursorInfo(hasNext, nextCursor, size); + } +} diff --git a/src/main/java/springboot/kakao_boot_camp/global/dto/PageInfo.java b/src/main/java/springboot/kakao_boot_camp/global/dto/PageInfo.java new file mode 100644 index 0000000..add7983 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/global/dto/PageInfo.java @@ -0,0 +1,19 @@ +package springboot.kakao_boot_camp.global.dto; + +import org.springframework.data.domain.Page; + +public record PageInfo( + int currentPage, + int totalPages, + long totalElements, + boolean hasNext +) { + public static PageInfo of(Page page) { + return new PageInfo( + page.getNumber(), + page.getTotalPages(), + page.getTotalElements(), + page.hasNext() + ); + } +} diff --git a/src/main/java/springboot/kakao_boot_camp/global/exception/handler/GlobalExceptionHandler.java b/src/main/java/springboot/kakao_boot_camp/global/exception/handler/GlobalExceptionHandler.java index 47aabea..46b39af 100644 --- a/src/main/java/springboot/kakao_boot_camp/global/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/springboot/kakao_boot_camp/global/exception/handler/GlobalExceptionHandler.java @@ -28,13 +28,8 @@ public ResponseEntity> handleBusinessException(BusinessExcepti } - - // -- Common -- - - - // 400 - @ExceptionHandler(MethodArgumentNotValidException.class) + @ExceptionHandler(MethodArgumentNotValidException.class) // 400 public ResponseEntity> handleValidationException(MethodArgumentNotValidException e) { ErrorCode errorCode = ErrorCode.INVALID_REQUEST; return ResponseEntity @@ -42,9 +37,7 @@ public ResponseEntity> handleValidationException(MethodArgumen .body(ApiResponse.error(errorCode)); } - - //409 - @ExceptionHandler(DuplicateResourceException.class) + @ExceptionHandler(DuplicateResourceException.class) //409 public ResponseEntity> hanlderDuplicateResource(DuplicateResourceException e){ ErrorCode errorCode = ErrorCode.DUPLICATE_EMAIL; return ResponseEntity @@ -52,4 +45,8 @@ public ResponseEntity> hanlderDuplicateResource(DuplicateResou .body(ApiResponse.error(errorCode)); // code : DUPLICATE_EMAIL, message : 이미 존재하는 이메일입니다 } + + + + } diff --git a/src/main/java/springboot/kakao_boot_camp/global/manager/CustomAuthManager.java b/src/main/java/springboot/kakao_boot_camp/global/manager/CustomAuthManager.java new file mode 100644 index 0000000..71595ab --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/global/manager/CustomAuthManager.java @@ -0,0 +1,7 @@ +package springboot.kakao_boot_camp.global.manager; + +import jakarta.servlet.http.HttpServletRequest; + +public interface CustomAuthManager { + public void create(HttpServletRequest httpServletRequest, Object object); +} diff --git a/src/main/java/springboot/kakao_boot_camp/security/CustomUserDetails.java b/src/main/java/springboot/kakao_boot_camp/security/CustomUserDetails.java new file mode 100644 index 0000000..a1f4c4c --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/security/CustomUserDetails.java @@ -0,0 +1,34 @@ +package springboot.kakao_boot_camp.security; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.User; + +import java.util.Collection; + +@Getter +@Setter +public class CustomUserDetails extends User { + private Long id; + + public CustomUserDetails( + springboot.kakao_boot_camp.domain.user.model.User user, + Collection authorities + ) { + super(user.getEmail(), user.getPassWord(), authorities); + } + + public CustomUserDetails( + Long userId, + String email, + Collection authorities + ) { + super(email, "", authorities); + this.id = userId; + } + + static public CustomUserDetails from(Long userId, String email, Collection authorities) { + return new CustomUserDetails(userId, email, authorities); + } +} diff --git a/src/main/java/springboot/kakao_boot_camp/security/config/SecurityConfig.java b/src/main/java/springboot/kakao_boot_camp/security/config/SecurityConfig.java new file mode 100644 index 0000000..e162241 --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/security/config/SecurityConfig.java @@ -0,0 +1,72 @@ +package springboot.kakao_boot_camp.security.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import springboot.kakao_boot_camp.security.filter.JwtFilter; +import springboot.kakao_boot_camp.security.handler.CustomAuthenticationEntryPoint; + +@Configuration +@RequiredArgsConstructor +@EnableWebSecurity +@Profile("custom-security") +public class SecurityConfig { + private final JwtFilter springSecuritySessionFilter; // 스프링 시큐리티 O, 세션 기반 인증 필터 + private final CustomAuthenticationEntryPoint authenticationEntryPoint; + + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .requestCache(cache -> cache.disable()) + + .csrf(AbstractHttpConfigurer::disable) + .cors(Customizer.withDefaults()) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(authenticationEntryPoint) + ) + + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/auth/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/posts/**").permitAll() // 조회는 모두 허용 + .requestMatchers("/api/v1/posts/**").authenticated() + .anyRequest().permitAll() + ) // 그 외 요청은 인증 필요 + .addFilterBefore(springSecuritySessionFilter, UsernamePasswordAuthenticationFilter.class) // 스프링 시큐리티 O, 세션 기반 인증 필터 + .build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.addAllowedOrigin("http://localhost:3000"); // 정확히 지정 + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + +} \ No newline at end of file diff --git a/src/main/java/springboot/kakao_boot_camp/security/filter/JwtFilter.java b/src/main/java/springboot/kakao_boot_camp/security/filter/JwtFilter.java new file mode 100644 index 0000000..89e3c4b --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/security/filter/JwtFilter.java @@ -0,0 +1,130 @@ +package springboot.kakao_boot_camp.security.filter; + +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import springboot.kakao_boot_camp.domain.auth.exception.InvalidTokenTypeException; +import springboot.kakao_boot_camp.domain.auth.exception.JwtTokenExpiredException; +import springboot.kakao_boot_camp.domain.auth.util.JwtUtil; +import springboot.kakao_boot_camp.domain.auth.util.Manager.login.jwt.TokenBlacklistManager; +import springboot.kakao_boot_camp.global.api.ErrorCode; +import springboot.kakao_boot_camp.security.CustomUserDetails; +import springboot.kakao_boot_camp.security.handler.CustomAuthenticationEntryPoint; + +import java.io.IOException; +import java.util.Arrays; + +@Component +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + private final JwtUtil jwtUtil; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final TokenBlacklistManager tokenBlacklistManager; + + + private String USER_ID_KEY = "userId"; + private String EMAIL_KEY = "email"; + private String ROLE_KEY = "role"; + + private static final String BEARER = "Bearer"; + + @Value("${jwt.access.secret}") + String accessSecret; + + + @Override + public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + // 1. 이미 인증된 상태면 패스 + if (SecurityContextHolder.getContext().getAuthentication() != null) { + filterChain.doFilter(request, response); + return; + } + + + + // 2. 토큰 확인 + String token = resolveAccessToken(request); // 토큰있는지 문자열 체크 + if (token == null) { // 토큰 없으면 그냥 통과 + filterChain.doFilter(request, response); // 넘어가요 + return; + } + + + + try { + // 3. 토큰 까서 claims 획득 + Claims claims = jwtUtil.extractAccessToken(token); // 토큰 까기 (토큰 진위여부 검증 해당 메서드에서 진행) + + + + // 4. 블랙리스트 체크 + String jti = claims.getId(); + if (jti != null && tokenBlacklistManager.isBlacklisted(jti)) { + request.setAttribute("exception", ErrorCode.TOKEN_LOGOUTED); + customAuthenticationEntryPoint.commence(request, response, new AuthenticationException("Token logged out") { + }); + return; + } + + + + //5. 블랙리스트 아닐 시, claim 페이로드 값 반환 + Long userId = claims.get("userId", Number.class).longValue(); + String email = claims.get("email").toString(); + var authorities = Arrays.stream( + claims.get("role").toString().split(",") + ).map(SimpleGrantedAuthority::new).toList(); + + + + // 6. 인증 정보 저장 = SecurityContextHolder에 등록 + CustomUserDetails customUserDetails = CustomUserDetails.from(userId, email, authorities); + var auth = new UsernamePasswordAuthenticationToken( + customUserDetails, null, customUserDetails.getAuthorities()); + auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // 추가 정보 저장 (어디서 접속했는지) + SecurityContextHolder.getContext().setAuthentication(auth); + + } catch (JwtTokenExpiredException e) { + request.setAttribute("exception", ErrorCode.TOKEN_EXPIRED); + customAuthenticationEntryPoint.commence(request, response, new AuthenticationException("Token expired") { + }); + return; + } catch (InvalidTokenTypeException e) { + request.setAttribute("exception", ErrorCode.INVALID_TOKEN_TYPE); + customAuthenticationEntryPoint.commence(request, response, new AuthenticationException("Invalid token") { + }); + return; + } + + + // 7. 다음 필터로 진행 + filterChain.doFilter(request, response); + } + + + private String resolveAccessToken(HttpServletRequest request) { + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + if (header != null && header.startsWith(BEARER)) { + String token = header.substring(BEARER.length()).trim(); + if(!token.equals("null")) + return token; + } + return null; + } +} + + + diff --git a/src/main/java/springboot/kakao_boot_camp/security/handler/CustomAuthenticationEntryPoint.java b/src/main/java/springboot/kakao_boot_camp/security/handler/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..ef8a55b --- /dev/null +++ b/src/main/java/springboot/kakao_boot_camp/security/handler/CustomAuthenticationEntryPoint.java @@ -0,0 +1,37 @@ +package springboot.kakao_boot_camp.security.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; +import springboot.kakao_boot_camp.global.api.ApiResponse; +import springboot.kakao_boot_camp.global.api.ErrorCode; + +import java.io.IOException; + +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override +public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException { + + // request에 담긴 예외 코드 확인 + ErrorCode errorCode = (ErrorCode) request.getAttribute("exception"); + if (errorCode == null) { + errorCode = ErrorCode.UNAUTHORIZED; // 기본값 + } + + ApiResponse errorResponse = ApiResponse.error(errorCode); + + response.setStatus(errorCode.getStatus().value()); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); +} + +} diff --git a/src/main/resources/.DS_Store b/src/main/resources/.DS_Store new file mode 100644 index 0000000..dc46edf Binary files /dev/null and b/src/main/resources/.DS_Store differ diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8e00f96..dd96946 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,26 +3,57 @@ spring: banner-mode: off datasource: - url : jdbc:mysql://localhost:3306/Community?serverTimezone=UTC&useSSL=false + url: jdbc:mysql://localhost:3306/Community?serverTimezone=UTC&useSSL=false username: root - password: driver-class-name: com.mysql.cj.jdbc.Driver jpa: + hibernate: + ddl-auto: none + properties: hibernate: - ddl-auto: update # 개발 중에는 create / create-drop / update 중 선택 - properties: - hibernate: - format_sql: true - use_sql_comments: true - - logging: - logging: - level: - root: INFO - org.springframework.web: DEBUG - org.springframework.security: DEBUG + format_sql: true + use_sql_comments: true + jdbc: + lob: + non_contextual_creation: true + show-sql: true + open-in-view: false + + session: + store-type: redis + redis: + namespace: shboard:session + + data: + redis: + host: localhost + password: 'gustjq3735!' + port: 6379 + + profiles: + active: custom-security server: port: 8080 + servlet: + session: + cookie: + name: JSESSIONID + +auth: + type: session + +jwt: + access: + secret: c2NhcmVkYWdlZmVhdGhlcnNwbGVudHlyZWFkeWVhdHByaW5jaXBhbHJpc2Vwcm9iYWI= + expiration: 900000 + refresh: + secret: d2hvbGVtYWlucG9yY2hsb29rZWFyZ3JlYXRseXNoaW5lcmVzdHRlbGxuZWVkbGVrZXA= + expiration: 604800000 +logging: + level: + root: INFO + org.springframework.web: DEBUG + org.springframework.security: DEBUG \ No newline at end of file diff --git a/src/main/resources/static/.DS_Store b/src/main/resources/static/.DS_Store new file mode 100644 index 0000000..1aa90dd Binary files /dev/null and b/src/main/resources/static/.DS_Store differ diff --git a/src/main/resources/static/auth/auth.css b/src/main/resources/static/auth/auth.css new file mode 100644 index 0000000..9e205a6 --- /dev/null +++ b/src/main/resources/static/auth/auth.css @@ -0,0 +1,52 @@ +/* auth.css */ +body { + font-family: Pretendard, sans-serif; + background: #f7f8fa; + display: flex; + justify-content: center; + align-items: center; + height: 100vh; +} + +.auth-container { + width: 320px; + background: white; + padding: 24px; + border-radius: 8px; + box-shadow: 0 2px 6px rgba(0,0,0,0.1); +} + +.auth-container h1 { + text-align: center; + margin-bottom: 20px; +} + +.auth-container form { + display: flex; + flex-direction: column; + gap: 10px; +} + +.auth-container input { + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; +} + +.auth-container button { + background: #3f51b5; + color: white; + border: none; + padding: 10px; + border-radius: 4px; + cursor: pointer; +} + +.auth-container button:hover { + background: #303f9f; +} + +.auth-container p { + text-align: center; + margin-top: 12px; +} diff --git a/src/main/resources/static/auth/auth.js b/src/main/resources/static/auth/auth.js new file mode 100644 index 0000000..628a9d4 --- /dev/null +++ b/src/main/resources/static/auth/auth.js @@ -0,0 +1,70 @@ +// auth.js +import { CONFIG } from "../common/config.js"; + + +const API_BASE = CONFIG.API_BASE; + +document.addEventListener("DOMContentLoaded", () => { + const signupForm = document.getElementById("signup-form"); + const loginForm = document.getElementById("login-form"); + + if (signupForm) { + signupForm.addEventListener("submit", async (e) => { + e.preventDefault(); + + const data = { + email: document.getElementById("email").value, + password: document.getElementById("password").value, + }; + + try { + const res = await fetch(`${API_BASE}/signup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + const json = await res.json(); + + if (res.ok) { + alert("회원가입이 완료되었습니다!"); + window.location.href = "./login.html"; + } else { + alert(json.message || "회원가입 실패"); + } + } catch (err) { + console.error(err); + alert("서버 연결 오류"); + } + }); + } + if (loginForm) { + loginForm.addEventListener("submit", async (e) => { + e.preventDefault(); + + const data = { + email: document.getElementById("email").value, + password: document.getElementById("password").value, + }; + + try { + const res = await fetch(`${API_BASE}/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + const json = await res.json(); + + if (res.ok) { + localStorage.setItem("accessToken", json.data.accessToken); + alert("로그인 성공!"); + window.location.href = "../post/list.html"; + } else { + alert(json.message || "로그인 실패"); + } + } catch (err) { + console.error(err); + alert("서버 연결 오류"); + } + }); + } +}); diff --git a/src/main/resources/static/auth/login.html b/src/main/resources/static/auth/login.html new file mode 100644 index 0000000..6f78c24 --- /dev/null +++ b/src/main/resources/static/auth/login.html @@ -0,0 +1,21 @@ + + + + + 로그인 | Kakao Bootcamp 커뮤니티 + + + +
+

로그인

+
+ + + +
+

계정이 없나요? 회원가입

+
+ + + + diff --git a/src/main/resources/static/auth/signup.html b/src/main/resources/static/auth/signup.html new file mode 100644 index 0000000..e118e59 --- /dev/null +++ b/src/main/resources/static/auth/signup.html @@ -0,0 +1,21 @@ + + + + + 회원가입 | 커뮤니티 + + + +
+

회원가입

+
+ + + +
+

이미 계정이 있나요? 로그인

+
+ + + + diff --git a/src/main/resources/static/common/common.css b/src/main/resources/static/common/common.css new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/static/common/common.js b/src/main/resources/static/common/common.js new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/static/common/config.js b/src/main/resources/static/common/config.js new file mode 100644 index 0000000..5f38b29 --- /dev/null +++ b/src/main/resources/static/common/config.js @@ -0,0 +1,12 @@ + +// 환경별로 API 서버 주소 관리 +export const CONFIG = { + API_BASE: "http://localhost:8080/api/v1", + AUTH_BASE: "http://localhost:8080/api/v1/auth", + USER_BASE: "http://localhost:8080/api/v1/users", +}; + +// 환경 감지로 자동 전환 +export const ENV = { + isDev: location.hostname === "localhost", +}; diff --git a/src/main/resources/static/common/footer.html b/src/main/resources/static/common/footer.html new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/static/common/header.html b/src/main/resources/static/common/header.html new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/static/post/detail.html b/src/main/resources/static/post/detail.html new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/static/post/form.html b/src/main/resources/static/post/form.html new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/static/post/list.html b/src/main/resources/static/post/list.html new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/static/post/post.css b/src/main/resources/static/post/post.css new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/static/post/post.js b/src/main/resources/static/post/post.js new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/static/user/mypage.html b/src/main/resources/static/user/mypage.html new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/static/user/password.html b/src/main/resources/static/user/password.html new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/static/user/user.css b/src/main/resources/static/user/user.css new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/static/user/user.js b/src/main/resources/static/user/user.js new file mode 100644 index 0000000..e69de29 diff --git a/src/test/java/springboot/kakao_boot_camp/KakaoBootCampApplicationTests.java b/src/test/java/springboot/kakao_boot_camp/KakaoBootCampApplicationTests.java deleted file mode 100644 index 8662023..0000000 --- a/src/test/java/springboot/kakao_boot_camp/KakaoBootCampApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package springboot.kakao_boot_camp; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class KakaoBootCampApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/springboot/kakao_boot_camp/service/PostServiceTest.java b/src/test/java/springboot/kakao_boot_camp/service/PostServiceTest.java new file mode 100644 index 0000000..11667e5 --- /dev/null +++ b/src/test/java/springboot/kakao_boot_camp/service/PostServiceTest.java @@ -0,0 +1,55 @@ +package springboot.kakao_boot_camp.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import springboot.kakao_boot_camp.domain.post.service.base.PostService; +import springboot.kakao_boot_camp.domain.post.dto.base.PostDtos.*; +import springboot.kakao_boot_camp.domain.post.entity.Post; +import springboot.kakao_boot_camp.domain.post.repository.base.PostRepository; +import springboot.kakao_boot_camp.domain.user.model.User; +import springboot.kakao_boot_camp.domain.user.repository.UserRepository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; + +import static org.mockito.BDDMockito.*; + + +@ExtendWith(MockitoExtension.class) +public class PostServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private PostRepository postRepository; + + @InjectMocks + private PostService postService; + + @Test + public void createPostTest() { + + + // given + User user = User.builder() + .Id(1L) + .build(); + given(userRepository.findById(1L)) + .willReturn(Optional.of(user)); + given(postRepository.save(any(Post.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + + // when + PostCreateReq req = new PostCreateReq("제목", "내용", "이미지"); + PostCreateRes res = postService.createPost(1L,req); + + + // then + assertThat(res.title()).isEqualTo("제목"); + } +}