diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9ed2374..aa34d17 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,5 +3,9 @@ ## ✨ 상세 설명 +## 🛠️ 추후 리팩토링 및 고도화 계획 + +## 📸 스크린샷 (선택) + ## 💬 리뷰 요구사항 \ No newline at end of file diff --git a/build.gradle b/build.gradle index 0e7fd79..909844d 100644 --- a/build.gradle +++ b/build.gradle @@ -24,6 +24,10 @@ repositories { mavenCentral() } +ext { + set('springCloudVersion', "2023.0.2") +} + dependencies { // Spring Boot Starters implementation 'org.springframework.boot:spring-boot-starter-data-jpa' @@ -33,7 +37,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' // Swagger - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' // Lombok compileOnly 'org.projectlombok:lombok' @@ -46,6 +50,23 @@ dependencies { // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // FeignClient + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.13.0' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + } } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml index 70fc4eb..06b8dfa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,5 +12,19 @@ services: PROD_DB_URL: ${PROD_DB_URL} PROD_DB_USERNAME: ${PROD_DB_USERNAME} PROD_DB_PASSWORD: ${PROD_DB_PASSWORD} + SPRING_DATA_REDIS_HOST: redis + SPRING_DATA_REDIS_PORT: 6379 ports: - - "8080:8080" \ No newline at end of file + - "8080:8080" + depends_on: + - redis + + redis: + image: redis:7-alpine + container_name: ongil-redis + ports: + - "6379:6379" + restart: always + volumes: + - ./redis_data:/data + command: ["redis-server", "--appendonly", "yes"] \ No newline at end of file diff --git a/src/main/java/com/ongil/backend/Application.java b/src/main/java/com/ongil/backend/Application.java index 462b0b4..6d8287d 100644 --- a/src/main/java/com/ongil/backend/Application.java +++ b/src/main/java/com/ongil/backend/Application.java @@ -2,10 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @EnableJpaAuditing @SpringBootApplication +@EnableFeignClients public class Application { public static void main(String[] args) { diff --git a/src/main/java/com/ongil/backend/domain/auth/client/google/GoogleApiClient.java b/src/main/java/com/ongil/backend/domain/auth/client/google/GoogleApiClient.java new file mode 100644 index 0000000..94580eb --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/client/google/GoogleApiClient.java @@ -0,0 +1,13 @@ +package com.ongil.backend.domain.auth.client.google; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; + +import com.ongil.backend.domain.auth.dto.response.GoogleUserInfoResDto; + +@FeignClient(name = "googleApiClient", url = "https://www.googleapis.com") +public interface GoogleApiClient { + @GetMapping("/oauth2/v3/userinfo") + GoogleUserInfoResDto getUserInfo(@RequestHeader("Authorization") String accessToken); +} diff --git a/src/main/java/com/ongil/backend/domain/auth/client/google/GoogleAuthClient.java b/src/main/java/com/ongil/backend/domain/auth/client/google/GoogleAuthClient.java new file mode 100644 index 0000000..4137ae9 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/client/google/GoogleAuthClient.java @@ -0,0 +1,20 @@ +package com.ongil.backend.domain.auth.client.google; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import com.ongil.backend.domain.auth.dto.response.GoogleTokenResDto; + +@FeignClient(name = "googleAuthClient", url = "https://oauth2.googleapis.com") +public interface GoogleAuthClient { + @PostMapping(value = "/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + GoogleTokenResDto getAccessToken( + @RequestParam("grant_type") String grantType, + @RequestParam("client_id") String clientId, + @RequestParam("client_secret") String clientSecret, + @RequestParam("redirect_uri") String redirectUri, + @RequestParam("code") String code + ); +} diff --git a/src/main/java/com/ongil/backend/domain/auth/client/kakao/KakaoApiClient.java b/src/main/java/com/ongil/backend/domain/auth/client/kakao/KakaoApiClient.java new file mode 100644 index 0000000..4d9ecf3 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/client/kakao/KakaoApiClient.java @@ -0,0 +1,15 @@ +package com.ongil.backend.domain.auth.client.kakao; + + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; + +import com.ongil.backend.domain.auth.dto.response.KakaoUserInfoResDto; + +// 유저 정보/API용 (kapi.kakao.com) +@FeignClient(name = "kakaoApiClient", url = "https://kapi.kakao.com") +public interface KakaoApiClient { + @GetMapping("/v2/user/me") + KakaoUserInfoResDto getUserInfo(@RequestHeader("Authorization") String bearerToken); +} diff --git a/src/main/java/com/ongil/backend/domain/auth/client/kakao/KakaoAuthClient.java b/src/main/java/com/ongil/backend/domain/auth/client/kakao/KakaoAuthClient.java new file mode 100644 index 0000000..cd8922c --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/client/kakao/KakaoAuthClient.java @@ -0,0 +1,21 @@ +package com.ongil.backend.domain.auth.client.kakao; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import com.ongil.backend.domain.auth.dto.response.KakaoTokenResDto; + +// 인증/토큰용 (kauth.kakao.com) +@FeignClient(name = "kakaoAuthClient", url = "https://kauth.kakao.com") +public interface KakaoAuthClient { + @PostMapping(value = "/oauth/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + KakaoTokenResDto getAccessToken( + @RequestParam("grant_type") String grantType, + @RequestParam("client_id") String clientId, + @RequestParam("redirect_uri") String redirectUri, + @RequestParam("code") String code, + @RequestParam("client_secret") String clientSecret + ); +} diff --git a/src/main/java/com/ongil/backend/domain/auth/controller/AuthController.java b/src/main/java/com/ongil/backend/domain/auth/controller/AuthController.java new file mode 100644 index 0000000..c3d6235 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/controller/AuthController.java @@ -0,0 +1,82 @@ +package com.ongil.backend.domain.auth.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.ongil.backend.domain.auth.dto.request.TokenRefreshReqDto; +import com.ongil.backend.domain.auth.dto.response.AuthResDto; +import com.ongil.backend.domain.auth.dto.response.TokenRefreshResDto; +import com.ongil.backend.domain.auth.service.AuthService; +import com.ongil.backend.domain.auth.service.GoogleLoginService; +import com.ongil.backend.domain.auth.service.KakaoLoginService; +import com.ongil.backend.global.common.dto.DataResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; + +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth") +public class AuthController { + + private final AuthService authService; + private final KakaoLoginService kakaoLoginService; + private final GoogleLoginService googleLoginService; + + @PostMapping("/oauth/kakao") + @Operation(summary = "카카오 회원가입/로그인 API", description = "인가코드(code)로 카카오 토큰 교환 후, 우리 서비스 JWT 발급") + public ResponseEntity> kakaoLogin( + @Valid @RequestParam("code") @NotBlank String code + ) { + AuthResDto res = kakaoLoginService.kakaoLogin(code); + return ResponseEntity.ok(DataResponse.from(res)); + } + + @GetMapping("/oauth/google") + @Operation(summary = "구글 회원가입/로그인 API", description = "인가코드(code)로 구글 토큰 교환 후, 우리 서비스 JWT 발급") + public ResponseEntity> googleLogin( + @Valid @RequestParam("code") @NotBlank String code + ) { + AuthResDto res = googleLoginService.googleLogin(code); + return ResponseEntity.ok(DataResponse.from(res)); + } + + @PostMapping("/token/refresh") + @Operation(summary = "Access/Refresh Token 재발급 API", description = "만료된 accessToken을 refreshToken을 통해 재발급") + public ResponseEntity> refresh( + @Valid @RequestBody TokenRefreshReqDto request + ) { + TokenRefreshResDto res = authService.refreshAccessToken(request.refreshToken()); + return ResponseEntity.ok(DataResponse.from(res)); + } + + @PostMapping("/logout") + @Operation(summary = "로그아웃 API", description = "Redis에 저장된 리프레시 토큰을 삭제하여 로그아웃 처리") + public ResponseEntity> logout( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId + ) { + authService.logout(userId); + return ResponseEntity.ok(DataResponse.from("로그아웃 되었습니다.")); + } + + @DeleteMapping("/withdraw") + @Operation(summary = "회원 탈퇴 API", description = "계정 삭제 및 리프레시 토큰 파기") + public ResponseEntity> withdraw( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId + ) { + authService.withdraw(userId); + return ResponseEntity.ok(DataResponse.from("회원 탈퇴가 완료되었습니다.")); + } +} diff --git a/src/main/java/com/ongil/backend/domain/auth/converter/AuthConverter.java b/src/main/java/com/ongil/backend/domain/auth/converter/AuthConverter.java new file mode 100644 index 0000000..16259d5 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/converter/AuthConverter.java @@ -0,0 +1,22 @@ +package com.ongil.backend.domain.auth.converter; + +import com.ongil.backend.domain.auth.dto.response.AuthResDto; +import com.ongil.backend.domain.user.entity.User; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public class AuthConverter { + + public static AuthResDto toResponse(User user, String accessToken, String refreshToken, + boolean isNewUser, long accessExpMs) { + return AuthResDto.builder() + .userId(user.getId()) + .accessToken(accessToken) + .refreshToken(refreshToken) + .loginType(user.getLoginType()) + .isNewUser(isNewUser) + .expires_in((int) (accessExpMs / 1000)) // 초 단위 + .build(); + } +} diff --git a/src/main/java/com/ongil/backend/domain/auth/dto/request/TokenRefreshReqDto.java b/src/main/java/com/ongil/backend/domain/auth/dto/request/TokenRefreshReqDto.java new file mode 100644 index 0000000..86af137 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/dto/request/TokenRefreshReqDto.java @@ -0,0 +1,11 @@ +package com.ongil.backend.domain.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record TokenRefreshReqDto( + @Schema(description = "리프레시토큰") + @NotNull + String refreshToken +) { +} diff --git a/src/main/java/com/ongil/backend/domain/auth/dto/response/AuthResDto.java b/src/main/java/com/ongil/backend/domain/auth/dto/response/AuthResDto.java new file mode 100644 index 0000000..eaaafe4 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/dto/response/AuthResDto.java @@ -0,0 +1,35 @@ +package com.ongil.backend.domain.auth.dto.response; + +import com.ongil.backend.domain.auth.entity.LoginType; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record AuthResDto( + + @Schema(description = "유저아이디") + Long userId, + + @Schema(description = "액세스토큰") + @NotNull + String accessToken, + + @Schema(description = "리프레시토큰") + @NotNull + String refreshToken, + + @Schema(description = "로그인 타입") + @NotNull + LoginType loginType, + + @Schema(description = "회원가입 여부(첫 로그인 여부)") + @NotNull + Boolean isNewUser, + + @Schema(description = "액세스 토큰 만료 시간") + @NotNull + Integer expires_in +) { +} diff --git a/src/main/java/com/ongil/backend/domain/auth/dto/response/GoogleTokenResDto.java b/src/main/java/com/ongil/backend/domain/auth/dto/response/GoogleTokenResDto.java new file mode 100644 index 0000000..ca9c918 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/dto/response/GoogleTokenResDto.java @@ -0,0 +1,10 @@ +package com.ongil.backend.domain.auth.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record GoogleTokenResDto( + @JsonProperty("access_token") String accessToken, + @JsonProperty("expires_in") Integer expiresIn, + @JsonProperty("token_type") String tokenType, + @JsonProperty("id_token") String idToken +) {} diff --git a/src/main/java/com/ongil/backend/domain/auth/dto/response/GoogleUserInfoResDto.java b/src/main/java/com/ongil/backend/domain/auth/dto/response/GoogleUserInfoResDto.java new file mode 100644 index 0000000..88f6f18 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/dto/response/GoogleUserInfoResDto.java @@ -0,0 +1,8 @@ +package com.ongil.backend.domain.auth.dto.response; + +public record GoogleUserInfoResDto( + String sub, // 구글의 고유 식별자 (카카오의 id 역할) + String name, + String email, + String picture +) {} diff --git a/src/main/java/com/ongil/backend/domain/auth/dto/response/KakaoTokenResDto.java b/src/main/java/com/ongil/backend/domain/auth/dto/response/KakaoTokenResDto.java new file mode 100644 index 0000000..1ec7232 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/dto/response/KakaoTokenResDto.java @@ -0,0 +1,13 @@ +package com.ongil.backend.domain.auth.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoTokenResDto( + @JsonProperty("access_token") String accessToken, + @JsonProperty("token_type") String tokenType, + @JsonProperty("refresh_token") String refreshToken, + @JsonProperty("expires_in") Integer expiresIn, + @JsonProperty("scope") String scope, + @JsonProperty("refresh_token_expires_in") Integer refreshTokenExpiresIn +) { +} diff --git a/src/main/java/com/ongil/backend/domain/auth/dto/response/KakaoUserInfoResDto.java b/src/main/java/com/ongil/backend/domain/auth/dto/response/KakaoUserInfoResDto.java new file mode 100644 index 0000000..654d8b0 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/dto/response/KakaoUserInfoResDto.java @@ -0,0 +1,18 @@ +package com.ongil.backend.domain.auth.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoUserInfoResDto( + Long id, + @JsonProperty("kakao_account") KakaoAccount kakaoAccount +) { + public record KakaoAccount( + String email, + Profile profile + ) { + public record Profile( + String nickname, + @JsonProperty("thumbnail_image_url") String profileImg + ) {} + } +} diff --git a/src/main/java/com/ongil/backend/domain/auth/dto/response/TokenRefreshResDto.java b/src/main/java/com/ongil/backend/domain/auth/dto/response/TokenRefreshResDto.java new file mode 100644 index 0000000..b5ac99a --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/dto/response/TokenRefreshResDto.java @@ -0,0 +1,7 @@ +package com.ongil.backend.domain.auth.dto.response; + +public record TokenRefreshResDto( + String accessToken, + String refreshToken +) { +} diff --git a/src/main/java/com/ongil/backend/domain/auth/service/AuthService.java b/src/main/java/com/ongil/backend/domain/auth/service/AuthService.java new file mode 100644 index 0000000..35bbabe --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/service/AuthService.java @@ -0,0 +1,58 @@ +package com.ongil.backend.domain.auth.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ongil.backend.domain.auth.dto.response.TokenRefreshResDto; +import com.ongil.backend.domain.user.repository.UserRepository; +import com.ongil.backend.global.common.exception.AppException; +import com.ongil.backend.global.common.exception.ErrorCode; +import com.ongil.backend.global.config.redis.RedisRefreshTokenStore; +import com.ongil.backend.global.security.jwt.JwtTokenProvider; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class AuthService { + + private final JwtTokenProvider jwtTokenProvider; + private final RedisRefreshTokenStore refreshTokenStore; + private final UserRepository userRepository; + + public TokenRefreshResDto refreshAccessToken(String refreshTokenValue) { + + if (!jwtTokenProvider.validateRefreshToken(refreshTokenValue)) { + throw new AppException(ErrorCode.INVALID_REFRESH_TOKEN); + } + + Long userId = jwtTokenProvider.getUserId(refreshTokenValue); + String savedToken = refreshTokenStore.getRefreshToken(String.valueOf(userId)); + + if (savedToken == null || !savedToken.equals(refreshTokenValue)) { + refreshTokenStore.removeRefreshToken(String.valueOf(userId)); + throw new AppException(ErrorCode.STOLEN_REFRESH_TOKEN); + } + + // 새 토큰 세트 생성 + String newAccessToken = jwtTokenProvider.createAccessToken(userId); + String newRefreshToken = jwtTokenProvider.createRefreshToken(userId); + + // Redis 업데이트 + refreshTokenStore.saveRefreshToken(String.valueOf(userId), newRefreshToken, jwtTokenProvider.getRefreshTokenExpireTime()); + + return new TokenRefreshResDto(newAccessToken, newRefreshToken); + } + + @Transactional + public void logout(Long userId) { + refreshTokenStore.removeRefreshToken(String.valueOf(userId)); + } + + @Transactional + public void withdraw(Long userId) { + refreshTokenStore.removeRefreshToken(String.valueOf(userId)); + userRepository.deleteById(userId); + } +} diff --git a/src/main/java/com/ongil/backend/domain/auth/service/GoogleLoginService.java b/src/main/java/com/ongil/backend/domain/auth/service/GoogleLoginService.java new file mode 100644 index 0000000..c7e4d6a --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/service/GoogleLoginService.java @@ -0,0 +1,112 @@ +package com.ongil.backend.domain.auth.service; + +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ongil.backend.domain.auth.client.google.GoogleApiClient; +import com.ongil.backend.domain.auth.client.google.GoogleAuthClient; +import com.ongil.backend.domain.auth.converter.AuthConverter; +import com.ongil.backend.domain.auth.dto.response.AuthResDto; +import com.ongil.backend.domain.auth.dto.response.GoogleTokenResDto; +import com.ongil.backend.domain.auth.dto.response.GoogleUserInfoResDto; +import com.ongil.backend.domain.auth.entity.LoginType; +import com.ongil.backend.domain.user.entity.User; +import com.ongil.backend.domain.user.repository.UserRepository; +import com.ongil.backend.global.common.exception.AppException; +import com.ongil.backend.global.common.exception.ErrorCode; +import com.ongil.backend.global.config.redis.RedisRefreshTokenStore; +import com.ongil.backend.global.security.jwt.JwtTokenProvider; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class GoogleLoginService { + + @Value("${google.client-id}") + private String googleclientId; + @Value("${google.client-secret}") + private String googleclientSecret; + @Value("${google.redirect-uri}") + private String googleredirectUri; + @Value("${jwt.access-expiration-ms}") + private long accessExpMs; + + private final UserRepository userRepository; + private final JwtTokenProvider jwtTokenProvider; + private final GoogleAuthClient googleAuthClient; + private final GoogleApiClient googleApiClient; + private final RedisRefreshTokenStore refreshTokenStore; + + public AuthResDto googleLogin(String code) { + String googleToken = getGoogleAccessToken(code); + GoogleUserInfoResDto userInfo = getGoogleUserInfo(googleToken); + String socialId = userInfo.sub(); + + if (socialId == null || socialId.isBlank()) { + throw new AppException(ErrorCode.INVALID_SOCIAL_USER_INFO); + } + + boolean isNewUser = !userRepository.existsByLoginTypeAndSocialId(LoginType.GOOGLE, socialId); + + User user = userRepository.findByLoginTypeAndSocialId(LoginType.GOOGLE, socialId) + .orElseGet(() -> userRepository.save( + User.builder() + .loginType(LoginType.GOOGLE) + .socialId(socialId) + .email(extractEmail(userInfo)) + .profileImg(extractProfileImg(userInfo)) + .name(extractName(userInfo)) + .build() + )); + + String accessToken = jwtTokenProvider.createAccessToken(user.getId()); + String refreshToken = jwtTokenProvider.createRefreshToken(user.getId()); + + refreshTokenStore.saveRefreshToken( + String.valueOf(user.getId()), + refreshToken, + jwtTokenProvider.getRefreshTokenExpireTime() + ); + + return AuthConverter.toResponse(user, accessToken, refreshToken, isNewUser, accessExpMs); + } + + private String getGoogleAccessToken(String code) { + GoogleTokenResDto token = googleAuthClient.getAccessToken( + "authorization_code", + googleclientId, + googleclientSecret, + googleredirectUri, + code + ); + return token.accessToken(); + } + + private GoogleUserInfoResDto getGoogleUserInfo(String accessToken) { + return googleApiClient.getUserInfo("Bearer " + accessToken); + } + + private String extractName(GoogleUserInfoResDto userInfo) { + return Optional.ofNullable(userInfo.name()) + .filter(name -> !name.isBlank()) + .orElse("google_user_" + userInfo.sub()); + } + + private String extractEmail(GoogleUserInfoResDto userInfo) { + if (userInfo.email() != null && !userInfo.email().isBlank()) { + return userInfo.email(); + } + return userInfo.sub() + "@google.user"; + } + + private String extractProfileImg(GoogleUserInfoResDto userInfo) { + return Optional.ofNullable(userInfo.picture()) + .filter(img -> !img.isBlank()) + .orElse(null); + } +} \ No newline at end of file diff --git a/src/main/java/com/ongil/backend/domain/auth/service/KakaoLoginService.java b/src/main/java/com/ongil/backend/domain/auth/service/KakaoLoginService.java new file mode 100644 index 0000000..239366a --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/auth/service/KakaoLoginService.java @@ -0,0 +1,123 @@ +package com.ongil.backend.domain.auth.service; + +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ongil.backend.domain.auth.converter.AuthConverter; +import com.ongil.backend.domain.auth.dto.response.AuthResDto; +import com.ongil.backend.domain.auth.dto.response.KakaoTokenResDto; +import com.ongil.backend.domain.auth.dto.response.KakaoUserInfoResDto; +import com.ongil.backend.domain.auth.entity.LoginType; +import com.ongil.backend.domain.auth.client.kakao.KakaoApiClient; +import com.ongil.backend.domain.auth.client.kakao.KakaoAuthClient; +import com.ongil.backend.domain.user.entity.User; +import com.ongil.backend.domain.user.repository.UserRepository; +import com.ongil.backend.global.common.exception.AppException; +import com.ongil.backend.global.common.exception.ErrorCode; +import com.ongil.backend.global.config.redis.RedisRefreshTokenStore; +import com.ongil.backend.global.security.jwt.JwtTokenProvider; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class KakaoLoginService { + + @Value("${kakao.client-id}") + private String kakaoclientId; + @Value("${kakao.client-secret}") + private String kakaoclientSecret; + @Value("${kakao.redirect-uri}") + private String kakaoredirectUri; + @Value("${jwt.access-expiration-ms}") + private long accessExpMs; + + private final UserRepository userRepository; + private final JwtTokenProvider jwtTokenProvider; + private final KakaoAuthClient kakaoAuthClient; + private final KakaoApiClient kakaoApiClient; + private final RedisRefreshTokenStore refreshTokenStore; + + public AuthResDto kakaoLogin(String code) { + // 카카오 Access Token + String kakaoToken = getKakaoAccessToken(code); + + // 카카오 사용자 정보 + KakaoUserInfoResDto userInfo = getKakaoUserInfo(kakaoToken); + + String socialId = (userInfo.id() != null) ? userInfo.id().toString() : null; + + if (socialId == null || socialId.isBlank()) { + throw new AppException(ErrorCode.INVALID_SOCIAL_USER_INFO); + } + + // 신규 유저 확인 + boolean isNewUser = !userRepository.existsByLoginTypeAndSocialId(LoginType.KAKAO, socialId); + + User user = userRepository.findByLoginTypeAndSocialId(LoginType.KAKAO, socialId) + .orElseGet(() -> userRepository.save( + User.builder() + .loginType(LoginType.KAKAO) + .socialId(socialId) + .email(extractEmail(userInfo)) + .profileImg(extractProfileImg(userInfo)) + .name(extractNickname(userInfo)) + .build() + )); + + // JWT 발급 + String accessToken = jwtTokenProvider.createAccessToken(user.getId()); + String refreshToken = jwtTokenProvider.createRefreshToken(user.getId()); + + refreshTokenStore.saveRefreshToken( + String.valueOf(user.getId()), + refreshToken, + jwtTokenProvider.getRefreshTokenExpireTime() + ); + + return AuthConverter.toResponse(user, accessToken, refreshToken, isNewUser, accessExpMs); + } + + // kakaoAuthClient를 사용하여 카카오로부터 access_token을 발급 + private String getKakaoAccessToken(String code) { + KakaoTokenResDto token = kakaoAuthClient.getAccessToken( + "authorization_code", + kakaoclientId, + kakaoredirectUri, + code, + kakaoclientSecret + ); + return token.accessToken(); + } + + // 카카오로부터 사용자 프로필 정보 추출 + private KakaoUserInfoResDto getKakaoUserInfo(String accessToken) { + return kakaoApiClient.getUserInfo("Bearer " + accessToken); + } + + private String extractNickname(KakaoUserInfoResDto userInfo) { + return Optional.ofNullable(userInfo.kakaoAccount()) + .map(KakaoUserInfoResDto.KakaoAccount::profile) + .map(KakaoUserInfoResDto.KakaoAccount.Profile::nickname) + .orElse("kakao_user_" + userInfo.id()); + } + + private String extractEmail(KakaoUserInfoResDto userInfo) { + if (userInfo.kakaoAccount() != null && userInfo.kakaoAccount().email() != null) { + return userInfo.kakaoAccount().email(); + } + // 이메일이 없을 시 임시 이메일 생성 (디비 제약조건) + return userInfo.id() + "@kakao.user"; + } + + private String extractProfileImg(KakaoUserInfoResDto userInfo) { + return Optional.ofNullable(userInfo.kakaoAccount()) + .map(KakaoUserInfoResDto.KakaoAccount::profile) + .map(KakaoUserInfoResDto.KakaoAccount.Profile::profileImg) + .orElse(null); + } +} diff --git a/src/main/java/com/ongil/backend/domain/user/controller/UserController.java b/src/main/java/com/ongil/backend/domain/user/controller/UserController.java new file mode 100644 index 0000000..dff5df2 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/user/controller/UserController.java @@ -0,0 +1,44 @@ +package com.ongil.backend.domain.user.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +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 com.ongil.backend.domain.user.dto.response.UserInfoResDto; +import com.ongil.backend.domain.user.service.UserService; +import com.ongil.backend.global.common.dto.DataResponse; + +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; + +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/user") +public class UserController { + + private final UserService userService; + + @GetMapping("/me") + @Operation(summary = "내 정보 조회 API", description = "현재 로그인한 사용자 정보를 조회") + public ResponseEntity> getMyInfo( + @AuthenticationPrincipal Long userId + ) { + UserInfoResDto res = userService.getUserInfo(userId); + return ResponseEntity.ok(DataResponse.from(res)); + } + + @GetMapping("/{userId}") + @Operation(summary = "특정 사용자 정보 조회 API", description = "ID를 통해 특정 사용자의 정보를 조회") + public ResponseEntity> getUserInfo( + @PathVariable(name = "userId") Long userId + ) { + UserInfoResDto res = userService.getUserInfo(userId); + return ResponseEntity.ok(DataResponse.from(res)); + } + +} diff --git a/src/main/java/com/ongil/backend/domain/user/converter/UserConverter.java b/src/main/java/com/ongil/backend/domain/user/converter/UserConverter.java new file mode 100644 index 0000000..a5afab8 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/user/converter/UserConverter.java @@ -0,0 +1,24 @@ +package com.ongil.backend.domain.user.converter; + +import com.ongil.backend.domain.user.dto.response.UserInfoResDto; +import com.ongil.backend.domain.user.entity.User; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public class UserConverter { + + public static UserInfoResDto toUserInfoResDto(User user) { + return UserInfoResDto.builder() + .userId(user.getId()) + .name(user.getName()) + .loginType(user.getLoginType()) + .phone(user.getPhone()) + .profileUrl(user.getProfileImg()) + .height(user.getHeight()) + .weight(user.getWeight()) + .usualSize(user.getUsualSize()) + .points(user.getPoints()) + .build(); + } +} diff --git a/src/main/java/com/ongil/backend/domain/user/dto/response/UserInfoResDto.java b/src/main/java/com/ongil/backend/domain/user/dto/response/UserInfoResDto.java new file mode 100644 index 0000000..968a67a --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/user/dto/response/UserInfoResDto.java @@ -0,0 +1,39 @@ +package com.ongil.backend.domain.user.dto.response; + +import com.ongil.backend.domain.auth.entity.LoginType; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +public record UserInfoResDto( + + @Schema(description = "유저 ID") + Long userId, + + @Schema(description = "이름") + String name, + + @Schema(description = "회원가입 경로") + LoginType loginType, + + @Schema(description = "프로필 이미지 URL") + String profileUrl, + + @Schema(description = "핸드폰 번호") + String phone, + + @Schema(description = "키") + Integer height, + + @Schema(description = "몸무게") + Integer weight, + + @Schema(description = "평소 착용 사이즈") + String usualSize, + + @Schema(description = "포인트") + Integer points +) {} + + diff --git a/src/main/java/com/ongil/backend/domain/user/entity/User.java b/src/main/java/com/ongil/backend/domain/user/entity/User.java index eb824a6..3f742bb 100644 --- a/src/main/java/com/ongil/backend/domain/user/entity/User.java +++ b/src/main/java/com/ongil/backend/domain/user/entity/User.java @@ -3,7 +3,7 @@ import java.util.*; import com.ongil.backend.domain.address.entity.*; -import com.ongil.backend.domain.user.enums.*; +import com.ongil.backend.domain.auth.entity.LoginType; import com.ongil.backend.global.common.entity.BaseEntity; import jakarta.persistence.*; @@ -11,9 +11,19 @@ import lombok.*; @Entity -@Table(name = "users") +@Table( + name = "users", + indexes = { // 조회 성능 향상 + @Index(name = "idx_login_type_social_id", columnList = "login_type,social_id") + }, + uniqueConstraints = { // 중복 가입 방지 + @UniqueConstraint(name = "uk_login_type_social_id", columnNames = {"login_type", "social_id"}) + } +) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter +@AllArgsConstructor +@Builder(toBuilder = true) public class User extends BaseEntity { @Id @@ -21,6 +31,13 @@ public class User extends BaseEntity { @Column(name = "user_id") private Long id; + @Enumerated(EnumType.STRING) + @Column(name = "login_type", nullable = false) + private LoginType loginType; + + @Column(name = "social_id", nullable = false) + private String socialId; + @Column(nullable = false, unique = true, length = 100) private String email; @@ -33,12 +50,6 @@ public class User extends BaseEntity { @Column(length = 20) private String phone; - @Column(name = "social_provider", length = 20) - private String socialProvider; - - @Column(name = "social_id", length = 100) - private String socialId; - private Integer height; private Integer weight; @@ -47,28 +58,10 @@ public class User extends BaseEntity { private String usualSize; @Column(nullable = false) + @Builder.Default private Integer points = 0; - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private UserRole role = UserRole.USER; - - @OneToMany(mappedBy = "user") + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default private List
addresses = new ArrayList<>(); - - @Builder - public User(String email, String name, String profileImg, String phone, String socialProvider, - String socialId, Integer height, Integer weight, String usualSize, - UserRole role) { - this.email = email; - this.name = name; - this.profileImg = profileImg; - this.phone = phone; - this.socialProvider = socialProvider; - this.socialId = socialId; - this.height = height; - this.weight = weight; - this.usualSize = usualSize; - this.role = role; - } } \ No newline at end of file diff --git a/src/main/java/com/ongil/backend/domain/user/enums/UserRole.java b/src/main/java/com/ongil/backend/domain/user/enums/UserRole.java deleted file mode 100644 index 20b3775..0000000 --- a/src/main/java/com/ongil/backend/domain/user/enums/UserRole.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.ongil.backend.domain.user.enums; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public enum UserRole { - USER("일반 사용자"), - ADMIN("관리자"); - - private final String description; -} diff --git a/src/main/java/com/ongil/backend/domain/user/repository/UserRepository.java b/src/main/java/com/ongil/backend/domain/user/repository/UserRepository.java index 1eaa739..35f8ec0 100644 --- a/src/main/java/com/ongil/backend/domain/user/repository/UserRepository.java +++ b/src/main/java/com/ongil/backend/domain/user/repository/UserRepository.java @@ -1,8 +1,17 @@ package com.ongil.backend.domain.user.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; +import com.ongil.backend.domain.auth.entity.LoginType; import com.ongil.backend.domain.user.entity.User; public interface UserRepository extends JpaRepository { + Optional findByLoginTypeAndSocialId( + LoginType loginType, + String socialId + ); + + boolean existsByLoginTypeAndSocialId(LoginType loginType, String socialId); } \ No newline at end of file diff --git a/src/main/java/com/ongil/backend/domain/user/service/UserService.java b/src/main/java/com/ongil/backend/domain/user/service/UserService.java new file mode 100644 index 0000000..e484a93 --- /dev/null +++ b/src/main/java/com/ongil/backend/domain/user/service/UserService.java @@ -0,0 +1,24 @@ +package com.ongil.backend.domain.user.service; + +import org.springframework.stereotype.Service; + +import com.ongil.backend.domain.user.converter.UserConverter; +import com.ongil.backend.domain.user.dto.response.UserInfoResDto; +import com.ongil.backend.domain.user.entity.User; +import com.ongil.backend.domain.user.repository.UserRepository; +import com.ongil.backend.global.common.exception.EntityNotFoundException; +import com.ongil.backend.global.common.exception.ErrorCode; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + + public UserInfoResDto getUserInfo(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException(ErrorCode.USER_NOT_FOUND)); + return UserConverter.toUserInfoResDto(user); + } +} diff --git a/src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java b/src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java index f488b2c..421e01e 100644 --- a/src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java +++ b/src/main/java/com/ongil/backend/global/common/exception/ErrorCode.java @@ -17,6 +17,14 @@ public enum ErrorCode { UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요합니다.", "COMMON-005"), FORBIDDEN(HttpStatus.FORBIDDEN, "접근 권한이 없습니다.", "COMMON-006"), + // AUTH + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 리프레시 토큰입니다.","AUTH-001"), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 존재하지 않습니다.","AUTH-002"), + STOLEN_REFRESH_TOKEN(HttpStatus.FORBIDDEN, "탈취된 토큰으로 의심됩니다. 다시 로그인해주세요.", "AUTH-003"), + INVALID_SOCIAL_USER_INFO(HttpStatus.BAD_REQUEST, "사용자 정보가 올바르지 않습니다.", "AUTH-004"), + + // USER + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다.","USER-001") ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/ongil/backend/global/config/CorsConfig.java b/src/main/java/com/ongil/backend/global/config/CorsConfig.java new file mode 100644 index 0000000..8e7cc99 --- /dev/null +++ b/src/main/java/com/ongil/backend/global/config/CorsConfig.java @@ -0,0 +1,48 @@ +package com.ongil.backend.global.config; + +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + private static final List ALLOWED_ORIGINS = List.of( + "https://ongil-fe.vercel.app", + "http://localhost:3000", + "http://3.38.199.67:8080", + "http://localhost:8080" + ); + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins(ALLOWED_ORIGINS.toArray(new String[0])) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") + .allowedHeaders("*") + .exposedHeaders("Authorization", "accessToken", "refreshToken") + .allowCredentials(true) + .maxAge(3600); + } + + // SecurityFilterChain에서 사용할 Bean + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(ALLOWED_ORIGINS); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); + config.setAllowedHeaders(List.of("*")); + config.setExposedHeaders(List.of("Authorization", "accessToken", "refreshToken")); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} \ No newline at end of file diff --git a/src/main/java/com/ongil/backend/global/config/SecurityConfig.java b/src/main/java/com/ongil/backend/global/config/SecurityConfig.java index e754933..5c4ef1d 100644 --- a/src/main/java/com/ongil/backend/global/config/SecurityConfig.java +++ b/src/main/java/com/ongil/backend/global/config/SecurityConfig.java @@ -2,23 +2,45 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -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.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfigurationSource; + +import com.ongil.backend.global.security.jwt.JwtAuthenticationFilter; + +import lombok.RequiredArgsConstructor; @Configuration +@EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CorsConfigurationSource corsConfigurationSource; + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http - .csrf(csrf -> csrf.disable()) // csrf().disable() 신버전 문법 - .authorizeHttpRequests(auth -> auth - .requestMatchers("/ping").permitAll() // 오류 없이 허용 - .anyRequest().permitAll() // 임시로 전체 허용 - ) - .formLogin(form -> form.disable()); // 기본 /login 페이지 비활성화 + .cors(cors -> cors.configurationSource(corsConfigurationSource)) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + + .authorizeHttpRequests(auth -> auth + .requestMatchers("/ping", "/h2-console/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers("/auth/logout", "/auth/withdraw").authenticated() + .requestMatchers("/auth/oauth/kakao", "/auth/oauth/google", "/auth/token/refresh").permitAll() + .requestMatchers("/auth/**").permitAll() + .anyRequest().authenticated() + ) + + // JWT 필터 적용 + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/src/main/java/com/ongil/backend/global/config/SwaggerConfig.java b/src/main/java/com/ongil/backend/global/config/SwaggerConfig.java index 02d2c3c..04a3974 100644 --- a/src/main/java/com/ongil/backend/global/config/SwaggerConfig.java +++ b/src/main/java/com/ongil/backend/global/config/SwaggerConfig.java @@ -1,22 +1,66 @@ package com.ongil.backend.global.config; +import java.util.List; + +import org.springdoc.core.properties.SwaggerUiConfigProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.core.env.Environment; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import lombok.RequiredArgsConstructor; @Configuration +@RequiredArgsConstructor public class SwaggerConfig { + private final Environment env; + @Bean public OpenAPI openAPI() { + String profile = env.getActiveProfiles().length > 0 ? env.getActiveProfiles()[0] : "local"; + + SecurityScheme accessTokenAuth = new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + .name("Authorization"); + + SecurityRequirement securityRequirement = new SecurityRequirement() + .addList("accessTokenAuth"); + + Server server = new Server(); + if ("dev".equals(profile)) { + server.setUrl("http://3.38.199.67:8080"); + server.setDescription("운영 서버"); + } else { + server.setUrl("http://localhost:8080"); + server.setDescription("로컬 서버"); + } + return new OpenAPI() .info(new Info() .title("Ongil API Documentation") - .description("Ongil 백엔드 API 문서입니다.") - .version("v1.0.0")); - + .description("온길 프로젝트 API 명세서입니다.") + .version("v1.0.0")) + .components(new Components() + .addSecuritySchemes("accessTokenAuth", accessTokenAuth)) + .addSecurityItem(securityRequirement) + .servers(List.of(server)); } -} + // Authorize 정보 유지 + @Bean + @Primary + public SwaggerUiConfigProperties swaggerUiConfigProperties(SwaggerUiConfigProperties props) { + props.setPersistAuthorization(true); + return props; + } +} \ No newline at end of file diff --git a/src/main/java/com/ongil/backend/global/config/redis/RedisConfig.java b/src/main/java/com/ongil/backend/global/config/redis/RedisConfig.java new file mode 100644 index 0000000..30f23a6 --- /dev/null +++ b/src/main/java/com/ongil/backend/global/config/redis/RedisConfig.java @@ -0,0 +1,30 @@ +package com.ongil.backend.global.config.redis; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } + + @Bean + public StringRedisTemplate stringRedisTemplate( + RedisConnectionFactory connectionFactory + ) { + return new StringRedisTemplate(connectionFactory); + } +} diff --git a/src/main/java/com/ongil/backend/global/config/redis/RedisRefreshTokenStore.java b/src/main/java/com/ongil/backend/global/config/redis/RedisRefreshTokenStore.java new file mode 100644 index 0000000..034c5bf --- /dev/null +++ b/src/main/java/com/ongil/backend/global/config/redis/RedisRefreshTokenStore.java @@ -0,0 +1,41 @@ +package com.ongil.backend.global.config.redis; + +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class RedisRefreshTokenStore { + + private final StringRedisTemplate stringRedisTemplate; + private static final String KEY_PREFIX = "RT:"; + + /** + * 리프레시 토큰 저장 (RTR 전략: 기존 키가 있으면 덮어쓰기) + * @param userId 유저 식별자 + * @param refreshToken 발급된 리프레시 토큰 + * @param expiryTimeMillis 토큰의 만료 시간 (ms) + */ + public void saveRefreshToken(String userId, String refreshToken, long expiryTimeMillis) { + stringRedisTemplate.opsForValue().set( + KEY_PREFIX + userId, + refreshToken, + expiryTimeMillis, + TimeUnit.MILLISECONDS + ); + } + + // 저장된 리프레시 토큰 조회 + public String getRefreshToken(String userId) { + return stringRedisTemplate.opsForValue().get(KEY_PREFIX + userId); + } + + // 리프레시 토큰 삭제 + public void removeRefreshToken(String userId) { + stringRedisTemplate.delete(KEY_PREFIX + userId); + } +} diff --git a/src/main/java/com/ongil/backend/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/ongil/backend/global/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..42150ee --- /dev/null +++ b/src/main/java/com/ongil/backend/global/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,56 @@ +package com.ongil.backend.global.security.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +// 이후 모든 요청에서 JWT를 검증하고 인증 상태로 만들어줌 +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + // 1. 요청 헤더에서 토큰 추출 + String token = resolveToken(request); + + // 2. 토큰이 유효한지 검사 + if (token != null && jwtTokenProvider.validateToken(token)) { + Long userId = jwtTokenProvider.getUserId(token); + + // 3. 스프링 시큐리티 인증 객체 생성 + List authorities = List.of(new SimpleGrantedAuthority("ROLE_USER")); + + Authentication auth = new UsernamePasswordAuthenticationToken(userId, null, authorities); + + // 4. SecurityContext에 인증 정보 저장 (이 요청이 실행되는 동안만 유효) + SecurityContextHolder.getContext().setAuthentication(auth); + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/ongil/backend/global/security/jwt/JwtTokenProvider.java b/src/main/java/com/ongil/backend/global/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..be47d82 --- /dev/null +++ b/src/main/java/com/ongil/backend/global/security/jwt/JwtTokenProvider.java @@ -0,0 +1,96 @@ +package com.ongil.backend.global.security.jwt; + +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; + +// 토큰 발급, 검증 +@Component +public class JwtTokenProvider { + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.access-expiration-ms}") + private long accessExpMs; + + @Value("${jwt.issuer}") + private String issuer; + + @Value("${jwt.refresh-expiration-ms}") + private long refreshExpMs; + + private SecretKey key; + + @PostConstruct + void init() { + this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + public String createAccessToken(Long userId) { + Date now = new Date(); + return Jwts.builder() + .issuer(issuer) + .subject(String.valueOf(userId)) + .issuedAt(now) + .expiration(new Date(now.getTime() + accessExpMs)) + .signWith(key) + .compact(); + } + + public String createRefreshToken(Long userId) { + Date now = new Date(); + return Jwts.builder() + .issuer(issuer) + .subject(String.valueOf(userId)) + .issuedAt(now) + .expiration(new Date(now.getTime() + refreshExpMs)) + .claim("type", "refresh") + .signWith(key) + .compact(); + } + + public boolean validateToken(String token) { + try { + parseClaims(token); + return true; + } catch (Exception e) { + return false; + } + } + + public boolean validateRefreshToken(String token) { + try { + Claims claims = parseClaims(token); + return "refresh".equals(claims.get("type")); + } catch (Exception e) { + return false; + } + } + + public Long getUserId(String token) { + return Long.valueOf(parseClaims(token).getSubject()); + } + + public long getRefreshTokenExpireTime() { + return refreshExpMs; + } + + private Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(key) + .requireIssuer(issuer) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 517eb01..68696ff 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -12,7 +12,7 @@ spring: jpa: hibernate: - ddl-auto: update + ddl-auto: create show-sql: true properties: hibernate: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0970e05..6691e8d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,6 +5,30 @@ spring: name: OnGil profiles: active: ${SPRING_PROFILES_ACTIVE:local} + data: + redis: + host: ${SPRING_DATA_REDIS_HOST:localhost} + port: ${SPRING_DATA_REDIS_PORT:6379} server: - port: 8080 \ No newline at end of file + port: 8080 + +jwt: + secret: ${JWT_SECRET} + access-expiration-ms: 3600000 + refresh-expiration-ms: 1209600000 + issuer: "ongil" + +kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: ${KAKAO_REDIRECT_URI} + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + +google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + redirect-uri: ${GOOGLE_REDIRECT_URI} + authorization-grant-type: authorization_code + scope: email, profile \ No newline at end of file