diff --git a/oldYoung/build.gradle b/oldYoung/build.gradle index 4f32978..2989f3d 100644 --- a/oldYoung/build.gradle +++ b/oldYoung/build.gradle @@ -28,6 +28,8 @@ dependencies { //Spring 의존성 implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' testImplementation 'org.springframework.boot:spring-boot-starter-test' //SpringSecurity @@ -40,6 +42,14 @@ dependencies { //DB runtimeOnly 'org.postgresql:postgresql' + + //JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + //Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.0' } tasks.named('test') { diff --git a/oldYoung/src/main/java/com/app/oldYoung/domain/entitlement/entity/Entitlement.java b/oldYoung/src/main/java/com/app/oldYoung/domain/entitlement/entity/Entitlement.java index 17c4ec5..a3ab0ba 100644 --- a/oldYoung/src/main/java/com/app/oldYoung/domain/entitlement/entity/Entitlement.java +++ b/oldYoung/src/main/java/com/app/oldYoung/domain/entitlement/entity/Entitlement.java @@ -1,6 +1,6 @@ package com.app.oldYoung.domain.entitlement.entity; -import com.app.oldYoung.global.common.BaseEntity; +import com.app.oldYoung.global.common.entity.BaseEntity; import com.app.oldYoung.domain.user.entity.User; import jakarta.persistence.*; import lombok.AccessLevel; diff --git a/oldYoung/src/main/java/com/app/oldYoung/domain/harume/entity/Harume.java b/oldYoung/src/main/java/com/app/oldYoung/domain/harume/entity/Harume.java index 41f158a..d57380c 100644 --- a/oldYoung/src/main/java/com/app/oldYoung/domain/harume/entity/Harume.java +++ b/oldYoung/src/main/java/com/app/oldYoung/domain/harume/entity/Harume.java @@ -1,6 +1,6 @@ package com.app.oldYoung.domain.harume.entity; -import com.app.oldYoung.global.common.BaseEntity; +import com.app.oldYoung.global.common.entity.BaseEntity; import com.app.oldYoung.domain.user.entity.User; import jakarta.persistence.*; import lombok.AccessLevel; diff --git a/oldYoung/src/main/java/com/app/oldYoung/domain/incomebracket/entity/IncomeBracket.java b/oldYoung/src/main/java/com/app/oldYoung/domain/incomebracket/entity/IncomeBracket.java index ee96afc..c2cde37 100644 --- a/oldYoung/src/main/java/com/app/oldYoung/domain/incomebracket/entity/IncomeBracket.java +++ b/oldYoung/src/main/java/com/app/oldYoung/domain/incomebracket/entity/IncomeBracket.java @@ -1,7 +1,7 @@ package com.app.oldYoung.domain.incomebracket.entity; import com.app.oldYoung.domain.user.entity.User; -import com.app.oldYoung.global.common.BaseEntity; +import com.app.oldYoung.global.common.entity.BaseEntity; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; diff --git a/oldYoung/src/main/java/com/app/oldYoung/domain/incomesnapshot/entity/IncomeSnapshot.java b/oldYoung/src/main/java/com/app/oldYoung/domain/incomesnapshot/entity/IncomeSnapshot.java index 2af22a3..1a6e7da 100644 --- a/oldYoung/src/main/java/com/app/oldYoung/domain/incomesnapshot/entity/IncomeSnapshot.java +++ b/oldYoung/src/main/java/com/app/oldYoung/domain/incomesnapshot/entity/IncomeSnapshot.java @@ -1,7 +1,7 @@ package com.app.oldYoung.domain.incomesnapshot.entity; import com.app.oldYoung.domain.user.entity.User; -import com.app.oldYoung.global.common.BaseEntity; +import com.app.oldYoung.global.common.entity.BaseEntity; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; diff --git a/oldYoung/src/main/java/com/app/oldYoung/domain/user/controller/AuthController.java b/oldYoung/src/main/java/com/app/oldYoung/domain/user/controller/AuthController.java new file mode 100644 index 0000000..e270b09 --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/domain/user/controller/AuthController.java @@ -0,0 +1,55 @@ +package com.app.oldYoung.domain.user.controller; + +import com.app.oldYoung.domain.user.converter.UserConverter; +import com.app.oldYoung.domain.user.dto.UserRequestDTO; +import com.app.oldYoung.domain.user.dto.UserResponseDTO; +import com.app.oldYoung.domain.user.entity.User; +import com.app.oldYoung.global.common.apiResponse.response.ApiResponse; +import com.app.oldYoung.global.security.service.AuthService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api") +public class AuthController { + + private final AuthService authService; + + /** + * 소셜 로그인 통합 엔드포인트 + * @param provider 'kakao', 'google' 등 소셜 로그인 제공자 + * @param accessCode 각 소셜 로그인 제공자로부터 받은 인가 코드 + * @return 로그인 또는 회원가입 결과 + */ + @GetMapping("/auth/login/{provider}") + public ResponseEntity> socialLogin( + @PathVariable("provider") String provider, + @RequestParam("code") String accessCode, + HttpServletResponse httpServletResponse) { + User user = authService.oAuthLogin(provider, accessCode, httpServletResponse); + UserResponseDTO.JoinResultDTO result = UserConverter.toJoinResultDTO(user); + return ResponseEntity.ok(ApiResponse.success(result)); + } + + @PostMapping("/auth/reissue") + public ResponseEntity reissueToken(HttpServletRequest request, HttpServletResponse response) { + authService.reissueToken(request, response); + return ResponseEntity.ok(ApiResponse.success("토큰이 성공적으로 재발급되었습니다.")); + } + + @PostMapping("/auth/logout") + public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { + authService.logout(request, response); + return ResponseEntity.ok(ApiResponse.success("로그아웃이 성공적으로 처리되었습니다.")); + } + + @PostMapping("/auth/logout/all") + public ResponseEntity logoutAll(@RequestParam String email, HttpServletResponse response) { + authService.logoutAll(email, response); + return ResponseEntity.ok(ApiResponse.success("모든 기기에서 로그아웃이 처리되었습니다.")); + } +} \ No newline at end of file diff --git a/oldYoung/src/main/java/com/app/oldYoung/domain/user/controller/UserController.java b/oldYoung/src/main/java/com/app/oldYoung/domain/user/controller/UserController.java new file mode 100644 index 0000000..41ba30c --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/domain/user/controller/UserController.java @@ -0,0 +1,32 @@ +package com.app.oldYoung.domain.user.controller; + +import com.app.oldYoung.global.common.apiResponse.response.ApiResponse; +import com.app.oldYoung.global.security.dto.UserPrincipal; +import lombok.RequiredArgsConstructor; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/user") +@RequiredArgsConstructor +public class UserController { + + /** + * 현재 로그인한 사용자 정보 조회 + */ + @GetMapping("/me") + public ResponseEntity getCurrentUser(@AuthenticationPrincipal UserPrincipal userPrincipal) { + Map currentUser = new HashMap<>(); + currentUser.put("id", userPrincipal.getId()); + currentUser.put("email", userPrincipal.getEmail()); + currentUser.put("membername", userPrincipal.getMembername()); + + return ResponseEntity.ok(ApiResponse.success(currentUser)); + } +} \ No newline at end of file diff --git a/oldYoung/src/main/java/com/app/oldYoung/domain/user/converter/UserConverter.java b/oldYoung/src/main/java/com/app/oldYoung/domain/user/converter/UserConverter.java new file mode 100644 index 0000000..84b8837 --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/domain/user/converter/UserConverter.java @@ -0,0 +1,15 @@ +package com.app.oldYoung.domain.user.converter; + +import com.app.oldYoung.domain.user.dto.UserResponseDTO; +import com.app.oldYoung.domain.user.entity.User; + +public class UserConverter { + + public static UserResponseDTO.JoinResultDTO toJoinResultDTO(User user) { + return UserResponseDTO.JoinResultDTO.builder() + .userId(user.getId()) + .email(user.getEmail()) + .membername(user.getMembername()) + .build(); + } +} \ No newline at end of file diff --git a/oldYoung/src/main/java/com/app/oldYoung/domain/user/dto/UserRequestDTO.java b/oldYoung/src/main/java/com/app/oldYoung/domain/user/dto/UserRequestDTO.java new file mode 100644 index 0000000..fdb637d --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/domain/user/dto/UserRequestDTO.java @@ -0,0 +1,16 @@ +package com.app.oldYoung.domain.user.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class UserRequestDTO { + + @Getter + @NoArgsConstructor + public static class LoginRequestDTO { + + private String email; + + private String password; + } +} \ No newline at end of file diff --git a/oldYoung/src/main/java/com/app/oldYoung/domain/user/dto/UserResponseDTO.java b/oldYoung/src/main/java/com/app/oldYoung/domain/user/dto/UserResponseDTO.java new file mode 100644 index 0000000..b5b4045 --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/domain/user/dto/UserResponseDTO.java @@ -0,0 +1,18 @@ +package com.app.oldYoung.domain.user.dto; + +import lombok.Builder; +import lombok.Getter; + +public class UserResponseDTO { + + @Getter + @Builder + public static class JoinResultDTO { + + private Long userId; + + private String email; + + private String membername; + } +} \ No newline at end of file diff --git a/oldYoung/src/main/java/com/app/oldYoung/domain/user/entity/User.java b/oldYoung/src/main/java/com/app/oldYoung/domain/user/entity/User.java index 2774133..d22b139 100644 --- a/oldYoung/src/main/java/com/app/oldYoung/domain/user/entity/User.java +++ b/oldYoung/src/main/java/com/app/oldYoung/domain/user/entity/User.java @@ -4,9 +4,10 @@ import com.app.oldYoung.domain.harume.entity.Harume; import com.app.oldYoung.domain.incomebracket.entity.IncomeBracket; import com.app.oldYoung.domain.incomesnapshot.entity.IncomeSnapshot; -import com.app.oldYoung.global.common.BaseEntity; +import com.app.oldYoung.global.common.entity.BaseEntity; import jakarta.persistence.*; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -34,6 +35,15 @@ public class User extends BaseEntity { @Column(name = "email") private String email; + @Column(name = "password") + private String password; + + @Column(name = "provider") + private String provider; + + @Column(name = "provider_id") + private String providerId; + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY) private IncomeBracket incomeBracket; @@ -45,4 +55,13 @@ public class User extends BaseEntity { @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY) private List entitlements; + + @Builder + public User(String membername, String email, String password, String provider, String providerId) { + this.membername = membername; + this.email = email; + this.password = password; + this.provider = provider; + this.providerId = providerId; + } } diff --git a/oldYoung/src/main/java/com/app/oldYoung/domain/user/repository/UserRepository.java b/oldYoung/src/main/java/com/app/oldYoung/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..b7321e3 --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/domain/user/repository/UserRepository.java @@ -0,0 +1,12 @@ +package com.app.oldYoung.domain.user.repository; + +import com.app.oldYoung.domain.user.entity.User; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + + Optional findByEmail(String email); + + Optional findByProviderAndProviderId(String provider, String providerId); +} diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/exception/CustomException.java b/oldYoung/src/main/java/com/app/oldYoung/global/common/apiResponse/exception/CustomException.java similarity index 95% rename from oldYoung/src/main/java/com/app/oldYoung/global/exception/CustomException.java rename to oldYoung/src/main/java/com/app/oldYoung/global/common/apiResponse/exception/CustomException.java index c09aff8..930349b 100644 --- a/oldYoung/src/main/java/com/app/oldYoung/global/exception/CustomException.java +++ b/oldYoung/src/main/java/com/app/oldYoung/global/common/apiResponse/exception/CustomException.java @@ -1,4 +1,4 @@ -package com.app.oldYoung.global.exception; +package com.app.oldYoung.global.common.apiResponse.exception; import lombok.Getter; diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/exception/ErrorCode.java b/oldYoung/src/main/java/com/app/oldYoung/global/common/apiResponse/exception/ErrorCode.java similarity index 51% rename from oldYoung/src/main/java/com/app/oldYoung/global/exception/ErrorCode.java rename to oldYoung/src/main/java/com/app/oldYoung/global/common/apiResponse/exception/ErrorCode.java index 1984b0e..e1abbca 100644 --- a/oldYoung/src/main/java/com/app/oldYoung/global/exception/ErrorCode.java +++ b/oldYoung/src/main/java/com/app/oldYoung/global/common/apiResponse/exception/ErrorCode.java @@ -1,35 +1,44 @@ -package com.app.oldYoung.global.exception; +package com.app.oldYoung.global.common.apiResponse.exception; import lombok.Getter; import org.springframework.http.HttpStatus; @Getter public enum ErrorCode { - + // System Errors (E100~E199) INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E100", "서버 내부 오류가 발생했습니다."), DATABASE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E101", "데이터베이스 오류가 발생했습니다."), - + PARSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E102", "데이터 파싱 중 오류가 발생했습니다."), + // Validation Errors (E200~E299) INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "E200", "입력값이 올바르지 않습니다."), MISSING_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST, "E201", "필수 파라미터가 누락되었습니다."), INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "E202", "데이터 타입이 올바르지 않습니다."), INVALID_FORMAT(HttpStatus.BAD_REQUEST, "E203", "데이터 형식이 올바르지 않습니다."), - + // Authentication & Authorization Errors (E300~E399) UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "E300", "인증이 필요합니다."), ACCESS_DENIED(HttpStatus.FORBIDDEN, "E301", "접근 권한이 없습니다."), - + OAUTH_TOKEN_REQUEST_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "E302", "OAuth 토큰 요청에 실패했습니다."), + OAUTH_PROFILE_REQUEST_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "E303", "OAuth 프로필 요청에 실패했습니다."), + JWT_TOKEN_CREATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "E304", "JWT 토큰 생성에 실패했습니다."), + JWT_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "E305", "JWT 토큰이 만료되었습니다."), + JWT_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "E306", "유효하지 않은 JWT 토큰입니다."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "E307", "리프레시 토큰을 찾을 수 없습니다."), + REFRESH_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "E308", "유효하지 않은 리프레시 토큰입니다."), + // Business Logic Errors (E400~E499) ENTITY_NOT_FOUND(HttpStatus.NOT_FOUND, "E400", "요청한 데이터를 찾을 수 없습니다."), - DUPLICATE_ENTITY(HttpStatus.CONFLICT, "E401", "중복된 데이터입니다."); - - private final HttpStatus status; + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "E401", "사용자를 찾을 수 없습니다."), + DUPLICATE_ENTITY(HttpStatus.CONFLICT, "E402", "중복된 데이터입니다."); + + private final HttpStatus httpStatus; private final String code; private final String message; - - ErrorCode(HttpStatus status, String code, String message) { - this.status = status; + + ErrorCode(HttpStatus httpStatus, String code, String message) { + this.httpStatus = httpStatus; this.code = code; this.message = message; } diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/exception/GlobalExceptionHandler.java b/oldYoung/src/main/java/com/app/oldYoung/global/common/apiResponse/exception/GlobalExceptionHandler.java similarity index 96% rename from oldYoung/src/main/java/com/app/oldYoung/global/exception/GlobalExceptionHandler.java rename to oldYoung/src/main/java/com/app/oldYoung/global/common/apiResponse/exception/GlobalExceptionHandler.java index c07b3e7..f3effb9 100644 --- a/oldYoung/src/main/java/com/app/oldYoung/global/exception/GlobalExceptionHandler.java +++ b/oldYoung/src/main/java/com/app/oldYoung/global/common/apiResponse/exception/GlobalExceptionHandler.java @@ -1,6 +1,6 @@ -package com.app.oldYoung.global.exception; +package com.app.oldYoung.global.common.apiResponse.exception; -import com.app.oldYoung.global.response.ApiResponse; +import com.app.oldYoung.global.common.apiResponse.response.ApiResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataAccessException; import org.springframework.http.ResponseEntity; @@ -24,7 +24,7 @@ public ResponseEntity> handleCustomException(CustomException e errorCode.getCode(), e.getMessage(), e.getContext()); return ResponseEntity - .status(errorCode.getStatus()) + .status(errorCode.getHttpStatus()) .body(ApiResponse.error(errorCode.getCode(), errorCode.getMessage())); } diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/response/ApiResponse.java b/oldYoung/src/main/java/com/app/oldYoung/global/common/apiResponse/response/ApiResponse.java similarity index 97% rename from oldYoung/src/main/java/com/app/oldYoung/global/response/ApiResponse.java rename to oldYoung/src/main/java/com/app/oldYoung/global/common/apiResponse/response/ApiResponse.java index 73c311f..d6e0fb5 100644 --- a/oldYoung/src/main/java/com/app/oldYoung/global/response/ApiResponse.java +++ b/oldYoung/src/main/java/com/app/oldYoung/global/common/apiResponse/response/ApiResponse.java @@ -1,4 +1,4 @@ -package com.app.oldYoung.global.response; +package com.app.oldYoung.global.common.apiResponse.response; import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonInclude; diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/response/SuccessCode.java b/oldYoung/src/main/java/com/app/oldYoung/global/common/apiResponse/response/SuccessCode.java similarity index 92% rename from oldYoung/src/main/java/com/app/oldYoung/global/response/SuccessCode.java rename to oldYoung/src/main/java/com/app/oldYoung/global/common/apiResponse/response/SuccessCode.java index 823950f..ec4b296 100644 --- a/oldYoung/src/main/java/com/app/oldYoung/global/response/SuccessCode.java +++ b/oldYoung/src/main/java/com/app/oldYoung/global/common/apiResponse/response/SuccessCode.java @@ -1,4 +1,4 @@ -package com.app.oldYoung.global.response; +package com.app.oldYoung.global.common.apiResponse.response; import lombok.Getter; import org.springframework.http.HttpStatus; diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/config/JpaConfig.java b/oldYoung/src/main/java/com/app/oldYoung/global/common/config/JpaConfig.java similarity index 80% rename from oldYoung/src/main/java/com/app/oldYoung/global/config/JpaConfig.java rename to oldYoung/src/main/java/com/app/oldYoung/global/common/config/JpaConfig.java index 749d7dc..564bb9d 100644 --- a/oldYoung/src/main/java/com/app/oldYoung/global/config/JpaConfig.java +++ b/oldYoung/src/main/java/com/app/oldYoung/global/common/config/JpaConfig.java @@ -1,4 +1,4 @@ -package com.app.oldYoung.global.config; +package com.app.oldYoung.global.common.config; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/common/config/RedisConfig.java b/oldYoung/src/main/java/com/app/oldYoung/global/common/config/RedisConfig.java new file mode 100644 index 0000000..4cb8ec9 --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/common/config/RedisConfig.java @@ -0,0 +1,50 @@ +package com.app.oldYoung.global.common.config; + +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.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Value("${spring.data.redis.password}") + private String password; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); + config.setHostName(host); + config.setPort(port); + + if (password != null && !password.trim().isEmpty()) { + config.setPassword(password); + } + + return new LettuceConnectionFactory(config); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + + StringRedisSerializer stringRedisSerializer = new StringRedisSerializer(); + redisTemplate.setKeySerializer(stringRedisSerializer); + redisTemplate.setValueSerializer(stringRedisSerializer); + redisTemplate.setHashKeySerializer(stringRedisSerializer); + redisTemplate.setHashValueSerializer(stringRedisSerializer); + + return redisTemplate; + } +} \ No newline at end of file diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/common/config/SwaggerConfig.java b/oldYoung/src/main/java/com/app/oldYoung/global/common/config/SwaggerConfig.java new file mode 100644 index 0000000..944f70e --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/common/config/SwaggerConfig.java @@ -0,0 +1,37 @@ +package com.app.oldYoung.global.common.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(apiInfo()) + .components(new Components() + .addSecuritySchemes("bearerAuth", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + .name("Authorization"))) + .addSecurityItem(new SecurityRequirement().addList("bearerAuth")); + } + + private Info apiInfo() { + return new Info() + .title("OldYoung API") + .description("OldYoung 프로젝트 API 문서") + .version("1.0.0"); + } +} \ No newline at end of file diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/controller/HealthCheckController.java b/oldYoung/src/main/java/com/app/oldYoung/global/common/controller/HealthCheckController.java similarity index 79% rename from oldYoung/src/main/java/com/app/oldYoung/global/controller/HealthCheckController.java rename to oldYoung/src/main/java/com/app/oldYoung/global/common/controller/HealthCheckController.java index f95be6c..e7cada1 100644 --- a/oldYoung/src/main/java/com/app/oldYoung/global/controller/HealthCheckController.java +++ b/oldYoung/src/main/java/com/app/oldYoung/global/common/controller/HealthCheckController.java @@ -1,6 +1,6 @@ -package com.app.oldYoung.global.controller; +package com.app.oldYoung.global.common.controller; -import com.app.oldYoung.global.response.ApiResponse; +import com.app.oldYoung.global.common.apiResponse.response.ApiResponse; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/common/BaseEntity.java b/oldYoung/src/main/java/com/app/oldYoung/global/common/entity/BaseEntity.java similarity index 94% rename from oldYoung/src/main/java/com/app/oldYoung/global/common/BaseEntity.java rename to oldYoung/src/main/java/com/app/oldYoung/global/common/entity/BaseEntity.java index 5206ec0..6eb1613 100644 --- a/oldYoung/src/main/java/com/app/oldYoung/global/common/BaseEntity.java +++ b/oldYoung/src/main/java/com/app/oldYoung/global/common/entity/BaseEntity.java @@ -1,4 +1,4 @@ -package com.app.oldYoung.global.common; +package com.app.oldYoung.global.common.entity; import jakarta.persistence.*; import lombok.Getter; diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/security/SecurityConfig.java b/oldYoung/src/main/java/com/app/oldYoung/global/security/SecurityConfig.java deleted file mode 100644 index 7ad50fd..0000000 --- a/oldYoung/src/main/java/com/app/oldYoung/global/security/SecurityConfig.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.app.oldYoung.global.security; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -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.web.SecurityFilterChain; - -@Configuration -@EnableWebSecurity -public class SecurityConfig { - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http - .csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); - - return http.build(); - } -} diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/security/config/SecurityConfig.java b/oldYoung/src/main/java/com/app/oldYoung/global/security/config/SecurityConfig.java new file mode 100644 index 0000000..4fefccd --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/security/config/SecurityConfig.java @@ -0,0 +1,66 @@ +package com.app.oldYoung.global.security.config; + +import com.app.oldYoung.global.security.filter.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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.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 java.util.Arrays; +import java.util.List; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/health", + "/api/auth/**", + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-ui.html" + ).permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of("http://localhost:3000", "http://localhost:8080", "http://localhost:5173")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/security/converter/AuthConverter.java b/oldYoung/src/main/java/com/app/oldYoung/global/security/converter/AuthConverter.java new file mode 100644 index 0000000..49c8bfe --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/security/converter/AuthConverter.java @@ -0,0 +1,18 @@ +package com.app.oldYoung.global.security.converter; + +import com.app.oldYoung.domain.user.entity.User; +import org.springframework.security.crypto.password.PasswordEncoder; + +public class AuthConverter { + + public static User toUser(String email, String membername, String password, String providerId, + PasswordEncoder passwordEncoder) { + return User.builder() + .email(email) + .membername(membername) + .password(password != null ? passwordEncoder.encode(password) : null) + .provider("kakao") + .providerId(providerId) + .build(); + } +} diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/security/dto/GoogleDTO.java b/oldYoung/src/main/java/com/app/oldYoung/global/security/dto/GoogleDTO.java new file mode 100644 index 0000000..862c662 --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/security/dto/GoogleDTO.java @@ -0,0 +1,15 @@ +package com.app.oldYoung.global.security.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; + +@Getter +@JsonIgnoreProperties(ignoreUnknown = true) +public class GoogleDTO { + + private String access_token; + private int expires_in; + private String scope; + private String token_type; + private String id_token; +} \ No newline at end of file diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/security/dto/KakaoDTO.java b/oldYoung/src/main/java/com/app/oldYoung/global/security/dto/KakaoDTO.java new file mode 100644 index 0000000..29a6021 --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/security/dto/KakaoDTO.java @@ -0,0 +1,25 @@ +package com.app.oldYoung.global.security.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; + +public class KakaoDTO { + + @Getter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class OAuthToken { + + private String access_token; + private String token_type; + private String refresh_token; + private int expires_in; + private String scope; + private int refresh_token_expires_in; + private String id_token; + } + + /** + * OIDC를 사용하면 ID Token에서 직접 프로필 정보를 얻으므로, + * 기존의 KakaoProfile 클래스는 더 이상 사용되지 않습니다. + */ +} diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/security/dto/UserPrincipal.java b/oldYoung/src/main/java/com/app/oldYoung/global/security/dto/UserPrincipal.java new file mode 100644 index 0000000..0211543 --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/security/dto/UserPrincipal.java @@ -0,0 +1,86 @@ +package com.app.oldYoung.global.security.dto; + +import com.app.oldYoung.domain.user.entity.User; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Collections; + +public class UserPrincipal implements UserDetails { + + private final Long id; + private final String email; + private final String membername; + private final String password; + private final Collection authorities; + + public UserPrincipal(Long id, String email, String membername, String password, + Collection authorities) { + this.id = id; + this.email = email; + this.membername = membername; + this.password = password; + this.authorities = authorities; + } + + public static UserPrincipal create(User user) { + Collection authorities = Collections.singletonList( + new SimpleGrantedAuthority("ROLE_USER") + ); + + return new UserPrincipal( + user.getId(), + user.getEmail(), + user.getMembername(), + user.getPassword(), + authorities + ); + } + + @Override + public String getUsername() { return email; } + + @Override + public String getPassword() { + return password; + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + + public Long getId() { + return id; + } + + public String getEmail() { + return email; + } + + public String getMembername() { + return membername; + } +} \ No newline at end of file diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/security/exception/AuthHandler.java b/oldYoung/src/main/java/com/app/oldYoung/global/security/exception/AuthHandler.java new file mode 100644 index 0000000..3a0aa65 --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/security/exception/AuthHandler.java @@ -0,0 +1,11 @@ +package com.app.oldYoung.global.security.exception; + +import com.app.oldYoung.global.common.apiResponse.exception.CustomException; +import com.app.oldYoung.global.common.apiResponse.exception.ErrorCode; + +public class AuthHandler extends CustomException { + + public AuthHandler(ErrorCode errorCode) { + super(errorCode); + } +} \ No newline at end of file diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/security/filter/JwtAuthenticationFilter.java b/oldYoung/src/main/java/com/app/oldYoung/global/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..9ab3491 --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,97 @@ +package com.app.oldYoung.global.security.filter; + +import com.app.oldYoung.global.security.service.CustomUserDetailsService; +import com.app.oldYoung.global.security.service.RefreshTokenService; +import com.app.oldYoung.global.security.util.JwtUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + private final RefreshTokenService refreshTokenService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + try { + String jwt = getJwtFromRequest(request); + + if (StringUtils.hasText(jwt)) { + // 1. 로그아웃된 토큰이 다시 사용되는 것을 방지하기 위해 블랙리스트 체크 + if (refreshTokenService.isTokenBlacklisted(jwt)) { + log.warn("블랙리스트된 토큰 사용 시도"); + filterChain.doFilter(request, response); + return; + } + + // JWT에서 이메일 추출 + String email = jwtUtil.getEmailFromToken(jwt); + + // 2. 중복 인증 방지: 이미 SecurityContext에 인증 정보가 있으면 건너뛴 + if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = customUserDetailsService.loadUserByUsername(email); + + // 토큰 유효성 검증 + if (!jwtUtil.isTokenExpired(jwt)) { + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + + log.debug("사용자 인증 완료: {}", email); + } + } + } + } catch (Exception e) { + log.error("JWT 인증 처리 중 오류 발생", e); + // 인증 실패 시 SecurityContext를 비워둠 + SecurityContextHolder.clearContext(); + } + + filterChain.doFilter(request, response); + } + + private String getJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); // "Bearer " 제거 + } + + return null; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + // 인증이 불필요한 경로들은 필터를 적용하지 않음 + String path = request.getServletPath(); + return path.startsWith("/health") || + path.startsWith("/auth/") || + path.startsWith("/swagger-ui") || + path.startsWith("/v3/api-docs") || + path.equals("/swagger-ui.html"); + } +} \ No newline at end of file diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/security/service/AuthService.java b/oldYoung/src/main/java/com/app/oldYoung/global/security/service/AuthService.java new file mode 100644 index 0000000..698ae8b --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/security/service/AuthService.java @@ -0,0 +1,140 @@ +package com.app.oldYoung.global.security.service; + +import com.app.oldYoung.domain.user.entity.User; +import com.app.oldYoung.domain.user.repository.UserRepository; +import com.app.oldYoung.global.common.apiResponse.exception.CustomException; +import com.app.oldYoung.global.common.apiResponse.exception.ErrorCode; +import com.app.oldYoung.global.security.dto.GoogleDTO; +import com.app.oldYoung.global.security.dto.KakaoDTO; +import com.app.oldYoung.global.security.util.CookieUtil; +import com.app.oldYoung.global.security.util.GoogleUtil; +import com.app.oldYoung.global.security.util.JwtUtil; +import com.app.oldYoung.global.security.util.KakaoUtil; +import io.jsonwebtoken.Claims; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final KakaoUtil kakaoUtil; + private final GoogleUtil googleUtil; + + private final UserRepository userRepository; + private final JwtUtil jwtUtil; + private final PasswordEncoder passwordEncoder; + private final CookieUtil cookieUtil; + private final RefreshTokenService refreshTokenService; + + /** + * 소셜 로그인 처리를 위한 메인 메소드입니다. provider 값에 따라 카카오 또는 구글 로그인 로직을 수행합니다. + * + * @param provider "kakao" 또는 "google" + * @param accessCode 각 소셜 로그인 제공자로부터 받은 인가 코드 + * @param httpServletResponse JWT 토큰을 담아 클라이언트에게 응답하기 위한 객체 + * @return 로그인 또는 신규 가입한 사용자 정보(User 엔티티) + */ + @Transactional + public User oAuthLogin(String provider, String accessCode, + HttpServletResponse httpServletResponse) { + User user; + Claims claims; + + // 1. provider에 따라 분기 처리 + if ("kakao".equalsIgnoreCase(provider)) { + // 1-1. 카카오 서버에 토큰 요청 + KakaoDTO.OAuthToken oAuthToken = kakaoUtil.requestToken(accessCode); + // 1-2. ID Token 검증 및 사용자 정보(Claims) 추출 + claims = jwtUtil.validateAndGetClaimsFromKakaoToken(oAuthToken.getId_token()); + + } else if ("google".equalsIgnoreCase(provider)) { + // 1-3. 구글 서버에 토큰 요청 + GoogleDTO oAuthToken = googleUtil.requestToken(accessCode); + // 1-4. ID Token 검증 및 사용자 정보(Claims) 추출 + claims = jwtUtil.validateAndGetClaimsFromGoogleToken(oAuthToken.getId_token()); + + } else { + throw new IllegalArgumentException("지원하지 않는 소셜 로그인 제공자입니다: " + provider); + } + + // 2. 추출한 Claims에서 사용자 정보 파싱 + String providerId = claims.getSubject(); // OIDC 표준에서 사용자를 식별하는 고유 ID + String email = claims.get("email", String.class); + // 1. 소셜 제공자별로 닉네임 필드명이 다르므로 조건부 처리 (Google: "name", Kakao: "nickname") + String nickname = "google".equalsIgnoreCase(provider) ? claims.get("name", String.class) + : claims.get("nickname", String.class); + + // 3. 사용자 정보로 DB 조회 또는 신규 회원가입 + user = processUser(provider, providerId, email, nickname); + + // 4. 우리 서비스의 JWT(Access/Refresh Token)를 생성하여 응답에 추가 + String accessToken = jwtUtil.createAccessToken(user.getEmail(), "USER"); + String refreshToken = jwtUtil.createRefreshToken(user.getEmail(), "USER"); + + refreshTokenService.saveRefreshToken(user.getEmail(), refreshToken); + httpServletResponse.setHeader("Authorization", "Bearer " + accessToken); + cookieUtil.addRefreshTokenCookie(httpServletResponse, refreshToken); + + return user; + } + + /** + * Provider로부터 받은 사용자 정보로 DB를 조회하고, 없으면 신규 가입시킵니다. + */ + private User processUser(String provider, String providerId, String email, String nickname) { + // 2. 기존 사용자 조회 후, 없으면 신규 생성 (orElseGet으로 Lazy Evaluation 적용) + return userRepository.findByProviderAndProviderId(provider, providerId) + .orElseGet(() -> createNewUser(provider, providerId, email, nickname)); + } + + private User createNewUser(String provider, String providerId, String email, String nickname) { + User newUser = User.builder() + .email(email) + .membername(nickname) + .password(null) + .provider(provider) + .providerId(providerId) + .build(); + return userRepository.save(newUser); + } + + @Transactional + public void reissueToken(HttpServletRequest request, HttpServletResponse response) { + String refreshToken = cookieUtil.getRefreshTokenFromCookie(request); + if (refreshToken == null) { + throw new CustomException(ErrorCode.REFRESH_TOKEN_NOT_FOUND); + } + String email = jwtUtil.getEmailFromToken(refreshToken); + // 3. Redis에 저장된 Refresh Token과 요청으로 받은 토큰 일치 여부 검증 + if (!refreshTokenService.validateRefreshToken(email, refreshToken)) { + throw new CustomException(ErrorCode.REFRESH_TOKEN_INVALID); + } + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + String newAccessToken = jwtUtil.createAccessToken(user.getEmail(), "USER"); + String newRefreshToken = jwtUtil.createRefreshToken(user.getEmail(), "USER"); + // 4. 새로운 Refresh Token을 Redis에 저장하여 기존 토큰 무효화 + refreshTokenService.saveRefreshToken(user.getEmail(), newRefreshToken); + response.setHeader("Authorization", "Bearer " + newAccessToken); + cookieUtil.addRefreshTokenCookie(response, newRefreshToken); + } + + public void logout(HttpServletRequest request, HttpServletResponse response) { + String refreshToken = cookieUtil.getRefreshTokenFromCookie(request); + if (refreshToken != null) { + String email = jwtUtil.getEmailFromToken(refreshToken); + refreshTokenService.deleteRefreshToken(email); + } + cookieUtil.removeRefreshTokenCookie(response); + } + + public void logoutAll(String email, HttpServletResponse response) { + refreshTokenService.deleteAllRefreshTokens(email); + cookieUtil.removeRefreshTokenCookie(response); + } +} \ No newline at end of file diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/security/service/CustomUserDetailsService.java b/oldYoung/src/main/java/com/app/oldYoung/global/security/service/CustomUserDetailsService.java new file mode 100644 index 0000000..5ff2c85 --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/security/service/CustomUserDetailsService.java @@ -0,0 +1,27 @@ +package com.app.oldYoung.global.security.service; + +import com.app.oldYoung.domain.user.entity.User; +import com.app.oldYoung.domain.user.repository.UserRepository; +import com.app.oldYoung.global.security.dto.UserPrincipal; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + @Transactional(readOnly = true) + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + email)); + + return UserPrincipal.create(user); + } +} \ No newline at end of file diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/security/service/RefreshTokenService.java b/oldYoung/src/main/java/com/app/oldYoung/global/security/service/RefreshTokenService.java new file mode 100644 index 0000000..7e5741b --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/security/service/RefreshTokenService.java @@ -0,0 +1,116 @@ +package com.app.oldYoung.global.security.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +@Slf4j +public class RefreshTokenService { + + private final RedisTemplate redisTemplate; + + @Value("${jwt.refresh-token-expiration}") + private long refreshTokenExpiration; + + // 1. RTR(Refresh Token Rotation) 방식으로 토큰 새로 저장 시 기존 토큰 자동 무효화 + public void saveRefreshToken(String email, String refreshToken) { + String key = getRefreshTokenKey(email); + try { + redisTemplate.opsForValue().set( + key, + refreshToken, + refreshTokenExpiration, + TimeUnit.MILLISECONDS + ); + log.info("리프레시 토큰 저장 완료: {}", email); + } catch (Exception e) { + log.error("리프레시 토큰 저장 실패: {}, error: {}", email, e.getMessage()); + throw new RuntimeException("리프레시 토큰 저장에 실패했습니다."); + } + } + + // 2. Redis에 저장된 토큰과 요청 토큰의 일치 여부 검증 + public boolean validateRefreshToken(String email, String refreshToken) { + String key = getRefreshTokenKey(email); + try { + String storedToken = redisTemplate.opsForValue().get(key); + boolean isValid = refreshToken.equals(storedToken); + + if (!isValid) { + log.warn("유효하지 않은 리프레시 토큰: {}", email); + } + + return isValid; + } catch (Exception e) { + log.error("리프레시 토큰 검증 실패: {}, error: {}", email, e.getMessage()); + return false; + } + } + + // 리프레시 토큰 삭제 (로그아웃 시) + public void deleteRefreshToken(String email) { + String key = getRefreshTokenKey(email); + try { + redisTemplate.delete(key); + log.info("리프레시 토큰 삭제 완료: {}", email); + } catch (Exception e) { + log.error("리프레시 토큰 삭제 실패: {}, error: {}", email, e.getMessage()); + } + } + + // 토큰 블랙리스트 추가 (보안 강화) + public void addToBlacklist(String token, long expiration) { + String key = getBlacklistKey(token); + try { + redisTemplate.opsForValue().set( + key, + "blacklisted", + expiration, + TimeUnit.MILLISECONDS + ); + log.info("토큰 블랙리스트 추가 완료"); + } catch (Exception e) { + log.error("토큰 블랙리스트 추가 실패: {}", e.getMessage()); + } + } + + // 토큰 블랙리스트 확인 + public boolean isTokenBlacklisted(String token) { + String key = getBlacklistKey(token); + try { + return redisTemplate.hasKey(key); + } catch (Exception e) { + log.error("토큰 블랙리스트 확인 실패: {}", e.getMessage()); + return false; + } + } + + // 3. 모든 디바이스에서 로그아웃 처리 (패턴 매칭으로 복수 토큰 삭제) + public void deleteAllRefreshTokens(String email) { + String pattern = "refresh_token:" + email + "*"; + try { + var keys = redisTemplate.keys(pattern); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + log.info("모든 리프레시 토큰 삭제 완료: {}", email); + } + } catch (Exception e) { + log.error("모든 리프레시 토큰 삭제 실패: {}, error: {}", email, e.getMessage()); + } + } + + private String getRefreshTokenKey(String email) { + return "refresh_token:" + email; + } + + private String getBlacklistKey(String token) { + // 4. 토큰 전체 값 대신 hashCode 사용으로 Redis 메모리 사용량 최적화 + return "blacklist:" + token.hashCode(); + } +} \ No newline at end of file diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/security/util/CookieUtil.java b/oldYoung/src/main/java/com/app/oldYoung/global/security/util/CookieUtil.java new file mode 100644 index 0000000..b2d7eb0 --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/security/util/CookieUtil.java @@ -0,0 +1,55 @@ +package com.app.oldYoung.global.security.util; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class CookieUtil { + + private final boolean isProduction; + private final int refreshTokenMaxAge; + + public CookieUtil( + @Value("${app.environment:local}") String environment, // 환경 설정 값 (local, production 등) + @Value("${jwt.refresh-token-expiration:604800000}") long refreshTokenExpiration) { + this.isProduction = "production".equals(environment); + this.refreshTokenMaxAge = (int) (refreshTokenExpiration / 1000); + } + + public void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) { + Cookie refreshCookie = new Cookie("refreshToken", refreshToken); + refreshCookie.setHttpOnly(true); + refreshCookie.setSecure(isProduction); + refreshCookie.setPath("/"); + refreshCookie.setMaxAge(refreshTokenMaxAge); + + if (isProduction) { + refreshCookie.setAttribute("SameSite", "Strict"); + } + + response.addCookie(refreshCookie); + } + + public String getRefreshTokenFromCookie(HttpServletRequest request) { + if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if ("refreshToken".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + return null; + } + + public void removeRefreshTokenCookie(HttpServletResponse response) { + Cookie refreshCookie = new Cookie("refreshToken", ""); + refreshCookie.setHttpOnly(true); + refreshCookie.setSecure(isProduction); + refreshCookie.setPath("/"); + refreshCookie.setMaxAge(0); + response.addCookie(refreshCookie); + } +} \ No newline at end of file diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/security/util/GoogleUtil.java b/oldYoung/src/main/java/com/app/oldYoung/global/security/util/GoogleUtil.java new file mode 100644 index 0000000..03bdc1c --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/security/util/GoogleUtil.java @@ -0,0 +1,78 @@ +package com.app.oldYoung.global.security.util; + +import com.app.oldYoung.global.common.apiResponse.exception.ErrorCode; +import com.app.oldYoung.global.security.dto.GoogleDTO; +import com.app.oldYoung.global.security.exception.AuthHandler; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +@Component +@Slf4j +public class GoogleUtil { + + private final WebClient webClient; + private final String clientId; + private final String clientSecret; + private final String redirectUri; + + public GoogleUtil( + WebClient.Builder webClientBuilder, + @Value("${spring.security.oauth2.client.registration.google.client-id}") String clientId, + @Value("${spring.security.oauth2.client.registration.google.client-secret}") String clientSecret, + @Value("${spring.security.oauth2.client.registration.google.redirect-uri}") String redirectUri + ) { + this.webClient = webClientBuilder.build(); + this.clientId = clientId; + this.clientSecret = clientSecret; + this.redirectUri = redirectUri; + } + + public GoogleDTO requestToken(String accessCode) { + try { + // URL 디코딩 처리 (이중 인코딩 해결) + String decodedCode = URLDecoder.decode(accessCode, StandardCharsets.UTF_8); + + // 여전히 인코딩된 상태라면 한 번 더 디코딩 + if (decodedCode.contains("%2F")) { + decodedCode = URLDecoder.decode(decodedCode, StandardCharsets.UTF_8); + } + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", clientId); + params.add("client_secret", clientSecret); + params.add("redirect_uri", redirectUri); + params.add("code", decodedCode); // 디코딩된 코드 사용 + + return webClient.post() + .uri("https://oauth2.googleapis.com/token") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .bodyValue(params) + .retrieve() + .onStatus( + status -> status.is4xxClientError() || status.is5xxServerError(), + response -> response.bodyToMono(String.class) + .doOnNext(body -> log.error("Google OAuth 에러 응답: {}", body)) + .then(Mono.error(new AuthHandler(ErrorCode.OAUTH_TOKEN_REQUEST_FAILED))) + ) + .bodyToMono(GoogleDTO.class) + .doOnError(error -> { + log.error("Google OAuth 토큰 요청 실패: {}", error.getMessage(), error); + }) + .block(); + + } catch (Exception e) { + log.error("Google OAuth 토큰 요청 중 예외 발생", e); + throw new AuthHandler(ErrorCode.OAUTH_TOKEN_REQUEST_FAILED); + } + } +} diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/security/util/JwtUtil.java b/oldYoung/src/main/java/com/app/oldYoung/global/security/util/JwtUtil.java new file mode 100644 index 0000000..b989d3d --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/security/util/JwtUtil.java @@ -0,0 +1,250 @@ +package com.app.oldYoung.global.security.util; + +import com.app.oldYoung.global.common.apiResponse.exception.CustomException; +import com.app.oldYoung.global.common.apiResponse.exception.ErrorCode; +import com.app.oldYoung.global.security.exception.AuthHandler; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import javax.crypto.SecretKey; +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +@Slf4j +public class JwtUtil { + + private final SecretKey secretKey; + private final long accessTokenExpiration; + private final long refreshTokenExpiration; + private final String kakaoClientId; + private final String googleClientId; + + private final Map publicKeys = new ConcurrentHashMap<>(); + + public JwtUtil( + @Value("${jwt.secret}") String secret, + @Value("${jwt.access-token-expiration}") long accessTokenExpiration, + @Value("${jwt.refresh-token-expiration}") long refreshTokenExpiration, + @Value("${spring.security.oauth2.client.registration.kakao.client-id}") String kakaoClientId, + @Value("${spring.security.oauth2.client.registration.google.client-id}") String googleClientId + ) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes()); + this.accessTokenExpiration = accessTokenExpiration; + this.refreshTokenExpiration = refreshTokenExpiration; + this.kakaoClientId = kakaoClientId; + this.googleClientId = googleClientId; + } + + /** + * [OIDC] 구글 ID Token의 유효성을 검증하고, 토큰에 담긴 사용자 정보(Claims)를 반환합니다. + * + * @param idToken 구글로부터 받은 ID Token 문자열 + * @return 사용자 정보가 담긴 Claims 객체 + */ + public Claims validateAndGetClaimsFromGoogleToken(String idToken) { + try { + String kid = getKidFromTokenHeader(idToken); + PublicKey publicKey = getPublicKey("google", kid); + + return Jwts.parserBuilder() + .setSigningKey(publicKey) + .requireIssuer("https://accounts.google.com") // iss가 구글인지 확인 + .requireAudience(googleClientId) // aud가 우리 앱 ID인지 확인 + .build() + .parseClaimsJws(idToken) + .getBody(); + } catch (ExpiredJwtException e) { + log.error("만료된 구글 ID Token입니다: {}", e.getMessage()); + throw new AuthHandler(ErrorCode.JWT_TOKEN_EXPIRED); + } catch (JwtException | IllegalArgumentException e) { + log.error("유효하지 않은 구글 ID Token입니다: {}", e.getMessage()); + throw new AuthHandler(ErrorCode.JWT_TOKEN_INVALID); + } + } + + /** + * [OIDC] 카카오 ID Token의 유효성을 검증하고, 토큰에 담긴 사용자 정보(Claims)를 반환합니다. + * + * @param idToken 카카오로부터 받은 ID Token 문자열 + * @return 사용자 정보가 담긴 Claims 객체 + */ + public Claims validateAndGetClaimsFromKakaoToken(String idToken) { + try { + String kid = getKidFromTokenHeader(idToken); + PublicKey publicKey = getPublicKey("kakao", kid); + + return Jwts.parserBuilder() + .setSigningKey(publicKey) + .requireIssuer("https://kauth.kakao.com") + .requireAudience(kakaoClientId) + .build() + .parseClaimsJws(idToken) + .getBody(); + } catch (ExpiredJwtException e) { + log.error("만료된 카카오 ID Token입니다: {}", e.getMessage()); + throw new AuthHandler(ErrorCode.JWT_TOKEN_EXPIRED); + } catch (JwtException | IllegalArgumentException e) { + log.error("유효하지 않은 카카오 ID Token입니다: {}", e.getMessage()); + throw new AuthHandler(ErrorCode.JWT_TOKEN_INVALID); + } + } + + /** + * [OIDC] Provider(카카오, 구글)와 kid(Key ID)를 받아 해당 공개키를 반환합니다. 내부에 캐싱 로직이 있어 한번 조회한 키는 다시 API 요청을 + * 하지 않습니다. + * + * @param provider "kakao" 또는 "google" + * @param kid 토큰 헤더에 명시된 Key ID + * @return 서명 검증에 사용할 PublicKey 객체 + */ + private PublicKey getPublicKey(String provider, String kid) { + String cacheKey = provider + "_" + kid; + // 1. 네트워크 요청 최소화를 위해 공개키 메모리 캐싱 체크 + if (publicKeys.containsKey(cacheKey)) { + return publicKeys.get(cacheKey); + } + + String jwksUri; + if ("kakao".equals(provider)) { + jwksUri = "https://kauth.kakao.com/.well-known/jwks.json"; + } else if ("google".equals(provider)) { + jwksUri = "https://www.googleapis.com/oauth2/v3/certs"; + } else { + throw new IllegalArgumentException("지원하지 않는 provider입니다."); + } + + RestTemplate restTemplate = new RestTemplate(); + Map>> jwks = restTemplate.getForObject(jwksUri, Map.class); + + PublicKey foundKey = null; + if (jwks != null && jwks.get("keys") != null) { + // 2. JWKS에서 모든 키를 캐싱하고, 요청된 kid와 일치하는 키 찾기 + for (Map keyInfo : jwks.get("keys")) { + String currentKid = keyInfo.get("kid"); + PublicKey publicKey = generatePublicKey(keyInfo); + publicKeys.put(provider + "_" + currentKid, publicKey); + if (kid.equals(currentKid)) { + foundKey = publicKey; + } + } + } + + if (foundKey == null) { + throw new CustomException(ErrorCode.JWT_TOKEN_INVALID, "일치하는 공개키를 찾을 수 없습니다."); + } + return foundKey; + } + + /** + * [OIDC] JWKS(JSON Web Key Set) 정보로부터 PublicKey 객체를 생성합니다. + */ + private PublicKey generatePublicKey(Map keyInfo) { + try { + byte[] nBytes = Base64.getUrlDecoder().decode(keyInfo.get("n")); + byte[] eBytes = Base64.getUrlDecoder().decode(keyInfo.get("e")); + + BigInteger n = new BigInteger(1, nBytes); + BigInteger e = new BigInteger(1, eBytes); + + RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePublic(publicKeySpec); + } catch (NoSuchAlgorithmException | InvalidKeySpecException ex) { + throw new CustomException(ErrorCode.JWT_TOKEN_INVALID, "공개키 생성에 실패했습니다.", ex); + } + } + + /** + * [OIDC] 토큰의 헤더를 디코딩하여 kid(Key ID)를 추출합니다. + */ + private String getKidFromTokenHeader(String token) { + try { + // 3. JWT 구조: header.payload.signature에서 header 부분만 Base64 디코딩 후 kid 추출 + String headerSegment = token.substring(0, token.indexOf('.')); + byte[] decodedHeader = Base64.getUrlDecoder().decode(headerSegment); + Map header = new ObjectMapper().readValue(new String(decodedHeader), + Map.class); + return (String) header.get("kid"); + } catch (JsonProcessingException | NullPointerException e) { + log.error("ID Token 헤더 파싱 실패", e); + throw new AuthHandler(ErrorCode.JWT_TOKEN_INVALID); + } + } + + public String createAccessToken(String email, String role) { + return createToken(email, role, accessTokenExpiration); + } + + public String createRefreshToken(String email, String role) { + return createToken(email, role, refreshTokenExpiration); + } + + private String createToken(String email, String role, long expiration) { + try { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + expiration); + + return Jwts.builder() + .setSubject(email) + .claim("role", role) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + } catch (Exception e) { + log.error("JWT 토큰 생성 실패: {}", e.getMessage()); + throw new AuthHandler(ErrorCode.JWT_TOKEN_CREATION_FAILED); + } + } + + public Claims validateToken(String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (ExpiredJwtException e) { + log.error("JWT 토큰이 만료되었습니다: {}", e.getMessage()); + throw new AuthHandler(ErrorCode.JWT_TOKEN_EXPIRED); + } catch (JwtException | IllegalArgumentException e) { + log.error("유효하지 않은 JWT 토큰입니다: {}", e.getMessage()); + throw new AuthHandler(ErrorCode.JWT_TOKEN_INVALID); + } + } + + public String getEmailFromToken(String token) { + Claims claims = validateToken(token); + return claims.getSubject(); + } + + public String getRoleFromToken(String token) { + Claims claims = validateToken(token); + return claims.get("role", String.class); + } + + public boolean isTokenExpired(String token) { + try { + Claims claims = validateToken(token); + return claims.getExpiration().before(new Date()); + } catch (AuthHandler e) { + return true; + } + } +} \ No newline at end of file diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/security/util/KakaoUtil.java b/oldYoung/src/main/java/com/app/oldYoung/global/security/util/KakaoUtil.java new file mode 100644 index 0000000..1da3e50 --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/security/util/KakaoUtil.java @@ -0,0 +1,55 @@ +package com.app.oldYoung.global.security.util; + +import com.app.oldYoung.global.common.apiResponse.exception.ErrorCode; +import com.app.oldYoung.global.security.dto.KakaoDTO; +import com.app.oldYoung.global.security.exception.AuthHandler; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.client.WebClient; + +@Component +@Slf4j +public class KakaoUtil { + + private final WebClient webClient; + + @Value("${spring.security.oauth2.client.registration.kakao.client-id}") + private String client; + + @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}") + private String redirect; + + public KakaoUtil(WebClient.Builder webClientBuilder) { + this.webClient = webClientBuilder.build(); + } + + public KakaoDTO.OAuthToken requestToken(String accessCode) { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", client); + params.add("redirect_uri", redirect); + params.add("code", accessCode); + + return webClient.post() + .uri("https://kauth.kakao.com/oauth/token") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .bodyValue(params) + .retrieve() + .bodyToMono(KakaoDTO.OAuthToken.class) + .doOnError(error -> { + log.error("OAuth 토큰 요청 실패: {}", error.getMessage()); + throw new AuthHandler(ErrorCode.OAUTH_TOKEN_REQUEST_FAILED); + }) + .block(); + } + + /** + * requestProfile 메소드는 OIDC 흐름에서 더 이상 사용되지 않습니다. + * ID Token에서 직접 프로필 정보를 추출하기 때문입니다. + */ +} diff --git a/oldYoung/src/main/resources/application.yml b/oldYoung/src/main/resources/application.yml index 3d81fb2..396660b 100644 --- a/oldYoung/src/main/resources/application.yml +++ b/oldYoung/src/main/resources/application.yml @@ -14,4 +14,45 @@ spring: show-sql: true properties: hibernate: - dialect: org.hibernate.dialect.PostgreSQLDialect \ No newline at end of file + dialect: org.hibernate.dialect.PostgreSQLDialect + + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: http://localhost:5173/auth/login/kakao + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + scope: + - openid + - profile_nickname + - account_email + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + redirect-uri: http://localhost:5173/auth/login/google + scope: + - openid + - profile + - email + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} +jwt: + secret: ${JWT_SECRET} + access-token-expiration: 3600000 + refresh-token-expiration: 604800000 + +app: + environment: ${APP_ENV:local}