From c00bc59bbe5f1658cbaab7607c182e0c2622cf17 Mon Sep 17 00:00:00 2001 From: margie1a <4987kk@naver.com> Date: Mon, 18 Aug 2025 23:22:05 +0900 Subject: [PATCH 01/16] =?UTF-8?q?[FEAT]=20Redis,=20RestTemplate,=20Swagger?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/common/config/RedisConfig.java | 50 +++++++++++++++++++ .../common/config/RestTemplateConfig.java | 24 +++++++++ .../global/common/config/SwaggerConfig.java | 37 ++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 oldYoung/src/main/java/com/app/oldYoung/global/common/config/RedisConfig.java create mode 100644 oldYoung/src/main/java/com/app/oldYoung/global/common/config/RestTemplateConfig.java create mode 100644 oldYoung/src/main/java/com/app/oldYoung/global/common/config/SwaggerConfig.java 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/RestTemplateConfig.java b/oldYoung/src/main/java/com/app/oldYoung/global/common/config/RestTemplateConfig.java new file mode 100644 index 0000000..1b3b0a6 --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/common/config/RestTemplateConfig.java @@ -0,0 +1,24 @@ +package com.app.oldYoung.global.common.config; + +import java.util.ArrayList; +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + RestTemplate restTemplate = new RestTemplate(); + + List> messageConverters = new ArrayList<>(); + messageConverters.add(new FormHttpMessageConverter()); + restTemplate.setMessageConverters(messageConverters); + + return restTemplate; + } +} \ 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 From ea40dd026fefd0523f06fafefb2063d5d435ac7d Mon Sep 17 00:00:00 2001 From: margie1a <4987kk@naver.com> Date: Mon, 18 Aug 2025 23:22:27 +0900 Subject: [PATCH 02/16] =?UTF-8?q?[FEAT]=20User=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B8=B0=EB=B3=B8=20=EA=B5=AC=EC=A1=B0=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/AuthController.java | 48 +++++++++++++++++++ .../user/controller/UserController.java | 32 +++++++++++++ .../domain/user/converter/UserConverter.java | 15 ++++++ .../domain/user/dto/UserRequestDTO.java | 16 +++++++ .../domain/user/dto/UserResponseDTO.java | 18 +++++++ .../user/repository/UserRepository.java | 12 +++++ 6 files changed, 141 insertions(+) create mode 100644 oldYoung/src/main/java/com/app/oldYoung/domain/user/controller/AuthController.java create mode 100644 oldYoung/src/main/java/com/app/oldYoung/domain/user/controller/UserController.java create mode 100644 oldYoung/src/main/java/com/app/oldYoung/domain/user/converter/UserConverter.java create mode 100644 oldYoung/src/main/java/com/app/oldYoung/domain/user/dto/UserRequestDTO.java create mode 100644 oldYoung/src/main/java/com/app/oldYoung/domain/user/dto/UserResponseDTO.java create mode 100644 oldYoung/src/main/java/com/app/oldYoung/domain/user/repository/UserRepository.java 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..850a539 --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/domain/user/controller/AuthController.java @@ -0,0 +1,48 @@ +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("") +public class AuthController { + + private final AuthService authService; + + @GetMapping("/auth/login/kakao") + public ResponseEntity> kakaoLogin( + @RequestParam("code") String accessCode, + HttpServletResponse httpServletResponse) { + User user = authService.oAuthLogin(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/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); +} From d97faed5b6deb1f5a0cad37133e311a30fa9d166 Mon Sep 17 00:00:00 2001 From: margie1a <4987kk@naver.com> Date: Mon, 18 Aug 2025 23:22:46 +0900 Subject: [PATCH 03/16] =?UTF-8?q?[FEAT]=20JWT=20=EB=B0=8F=20OAuth=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EA=B4=80=EB=A0=A8=20DTO,=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=EB=A6=AC=ED=8B=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/converter/AuthConverter.java | 18 ++++ .../global/security/dto/KakaoDTO.java | 57 ++++++++++++ .../global/security/dto/UserPrincipal.java | 86 +++++++++++++++++ .../security/exception/AuthHandler.java | 11 +++ .../global/security/util/CookieUtil.java | 55 +++++++++++ .../global/security/util/JwtUtil.java | 91 ++++++++++++++++++ .../global/security/util/KakaoUtil.java | 92 +++++++++++++++++++ 7 files changed, 410 insertions(+) create mode 100644 oldYoung/src/main/java/com/app/oldYoung/global/security/converter/AuthConverter.java create mode 100644 oldYoung/src/main/java/com/app/oldYoung/global/security/dto/KakaoDTO.java create mode 100644 oldYoung/src/main/java/com/app/oldYoung/global/security/dto/UserPrincipal.java create mode 100644 oldYoung/src/main/java/com/app/oldYoung/global/security/exception/AuthHandler.java create mode 100644 oldYoung/src/main/java/com/app/oldYoung/global/security/util/CookieUtil.java create mode 100644 oldYoung/src/main/java/com/app/oldYoung/global/security/util/JwtUtil.java create mode 100644 oldYoung/src/main/java/com/app/oldYoung/global/security/util/KakaoUtil.java 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/KakaoDTO.java b/oldYoung/src/main/java/com/app/oldYoung/global/security/dto/KakaoDTO.java new file mode 100644 index 0000000..7542d37 --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/security/dto/KakaoDTO.java @@ -0,0 +1,57 @@ +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; + } + + @Getter + @JsonIgnoreProperties(ignoreUnknown = true) + public static class KakaoProfile { + + private Long id; + private String connected_at; + private Properties properties; + private KakaoAccount kakao_account; + + @Getter + @JsonIgnoreProperties(ignoreUnknown = true) + public class Properties { + + private String nickname; + } + + @Getter + @JsonIgnoreProperties(ignoreUnknown = true) + public class KakaoAccount { + + private String email; + private Boolean is_email_verified; + private Boolean has_email; + private Boolean profile_nickname_needs_agreement; + private Boolean email_needs_agreement; + private Boolean is_email_valid; + private Profile profile; + + @Getter + @JsonIgnoreProperties(ignoreUnknown = true) + public class Profile { + + private String nickname; + private Boolean is_default_nickname; + } + } + } +} 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/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/JwtUtil.java b/oldYoung/src/main/java/com/app/oldYoung/global/security/util/JwtUtil.java new file mode 100644 index 0000000..e043317 --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/security/util/JwtUtil.java @@ -0,0 +1,91 @@ +package com.app.oldYoung.global.security.util; + +import com.app.oldYoung.global.common.apiResponse.exception.ErrorCode; +import com.app.oldYoung.global.security.exception.AuthHandler; +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 javax.crypto.SecretKey; +import java.util.Date; + +@Component +@Slf4j +public class JwtUtil { + + private final SecretKey secretKey; + private final long accessTokenExpiration; + private final long refreshTokenExpiration; + + public JwtUtil( + @Value("${jwt.secret}") String secret, + @Value("${jwt.access-token-expiration}") long accessTokenExpiration, + @Value("${jwt.refresh-token-expiration}") long refreshTokenExpiration) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes()); + this.accessTokenExpiration = accessTokenExpiration; + this.refreshTokenExpiration = refreshTokenExpiration; + } + + 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() + .subject(email) + .claim("role", role) + .issuedAt(now) + .expiration(expiryDate) + .signWith(secretKey, Jwts.SIG.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.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } 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..2c9d346 --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/security/util/KakaoUtil.java @@ -0,0 +1,92 @@ +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 com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.util.Arrays; + +@Component +@Slf4j +public class KakaoUtil { + + @Value("${spring.kakao.auth.client}") + private String client; + @Value("${spring.kakao.auth.redirect}") + private String redirect; + + public KakaoDTO.OAuthToken requestToken(String accessCode) { + RestTemplate restTemplate = new RestTemplate(); + HttpHeaders headers = new HttpHeaders(); + headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8"); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", client); + params.add("redirect_url", redirect); + params.add("code", accessCode); + + HttpEntity> kakaoTokenRequest = new HttpEntity<>(params, + headers); + + ResponseEntity response = restTemplate.exchange( + "https://kauth.kakao.com/oauth/token", + HttpMethod.POST, + kakaoTokenRequest, + String.class); + + ObjectMapper objectMapper = new ObjectMapper(); + + KakaoDTO.OAuthToken oAuthToken = null; + + try { + oAuthToken = objectMapper.readValue(response.getBody(), KakaoDTO.OAuthToken.class); + log.info("oAuthToken : " + oAuthToken.getAccess_token()); + } catch (JsonProcessingException e) { + log.error("OAuth 토큰 파싱 실패: {}", e.getMessage()); + throw new AuthHandler(ErrorCode.PARSING_ERROR); + } + return oAuthToken; + } + + public KakaoDTO.KakaoProfile requestProfile(KakaoDTO.OAuthToken oAuthToken) { + RestTemplate restTemplate2 = new RestTemplate(); + HttpHeaders headers2 = new HttpHeaders(); + + headers2.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8"); + headers2.add("Authorization", "Bearer " + oAuthToken.getAccess_token()); + + HttpEntity> kakaoProfileRequest = new HttpEntity<>(headers2); + + ResponseEntity response2 = restTemplate2.exchange( + "https://kapi.kakao.com/v2/user/me", + HttpMethod.GET, + kakaoProfileRequest, + String.class); + + ObjectMapper objectMapper = new ObjectMapper(); + + KakaoDTO.KakaoProfile kakaoProfile = null; + + try { + kakaoProfile = objectMapper.readValue(response2.getBody(), KakaoDTO.KakaoProfile.class); + } catch (JsonProcessingException e) { + log.error("OAuth 프로필 파싱 실패: {}", e.getMessage()); + throw new AuthHandler(ErrorCode.PARSING_ERROR); + } + + return kakaoProfile; + } +} \ No newline at end of file From f1f157515abca28c42ebd8096a363dee160d024d Mon Sep 17 00:00:00 2001 From: margie1a <4987kk@naver.com> Date: Mon, 18 Aug 2025 23:23:10 +0900 Subject: [PATCH 04/16] =?UTF-8?q?[FEAT]=20JWT=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EB=B0=8F=20=EB=B3=B4=EC=95=88=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filter/JwtAuthenticationFilter.java | 97 +++++++++++++ .../global/security/service/AuthService.java | 133 ++++++++++++++++++ .../service/CustomUserDetailsService.java | 27 ++++ .../security/service/RefreshTokenService.java | 115 +++++++++++++++ 4 files changed, 372 insertions(+) create mode 100644 oldYoung/src/main/java/com/app/oldYoung/global/security/filter/JwtAuthenticationFilter.java create mode 100644 oldYoung/src/main/java/com/app/oldYoung/global/security/service/AuthService.java create mode 100644 oldYoung/src/main/java/com/app/oldYoung/global/security/service/CustomUserDetailsService.java create mode 100644 oldYoung/src/main/java/com/app/oldYoung/global/security/service/RefreshTokenService.java 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..ecff6aa --- /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)) { + // 토큰 블랙리스트 확인 + if (refreshTokenService.isTokenBlacklisted(jwt)) { + log.warn("블랙리스트된 토큰 사용 시도"); + filterChain.doFilter(request, response); + return; + } + + // JWT에서 이메일 추출 + String email = jwtUtil.getEmailFromToken(jwt); + + // 이미 인증된 사용자가 아닌 경우에만 처리 + 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..c0122fc --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/security/service/AuthService.java @@ -0,0 +1,133 @@ +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.converter.AuthConverter; +import com.app.oldYoung.global.security.dto.KakaoDTO; +import com.app.oldYoung.global.security.util.CookieUtil; +import com.app.oldYoung.global.security.util.JwtUtil; +import com.app.oldYoung.global.security.util.KakaoUtil; +import com.app.oldYoung.global.common.apiResponse.exception.CustomException; +import com.app.oldYoung.global.common.apiResponse.exception.ErrorCode; +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; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final KakaoUtil kakaoUtil; + private final UserRepository userRepository; + private final JwtUtil jwtUtil; + private final PasswordEncoder passwordEncoder; + private final CookieUtil cookieUtil; + private final RefreshTokenService refreshTokenService; + + public User oAuthLogin(String accessCode, HttpServletResponse httpServletResponse) { + KakaoDTO.OAuthToken oAuthToken = kakaoUtil.requestToken(accessCode); + KakaoDTO.KakaoProfile kakaoProfile = kakaoUtil.requestProfile(oAuthToken); + String providerId = String.valueOf(kakaoProfile.getId()); + + User user = userRepository.findByProviderAndProviderId("kakao", providerId) + .orElseGet(() -> createNewUser(kakaoProfile)); + + String accessToken = jwtUtil.createAccessToken(user.getEmail(), "USER"); + String refreshToken = jwtUtil.createRefreshToken(user.getEmail(), "USER"); + + // Redis에 리프레시 토큰 저장 + refreshTokenService.saveRefreshToken(user.getEmail(), refreshToken); + + httpServletResponse.setHeader("Authorization", "Bearer " + accessToken); + cookieUtil.addRefreshTokenCookie(httpServletResponse, refreshToken); + + return user; + } + + private User createNewUser(KakaoDTO.KakaoProfile kakaoProfile) { + User newUser = AuthConverter.toUser( + kakaoProfile.getKakao_account().getEmail(), + kakaoProfile.getKakao_account().getProfile().getNickname(), + null, + String.valueOf(kakaoProfile.getId()), + passwordEncoder + ); + return userRepository.save(newUser); + } + + public void reissueToken(HttpServletRequest request, HttpServletResponse response) { + String refreshToken = cookieUtil.getRefreshTokenFromCookie(request); + + if (refreshToken == null) { + throw new CustomException(ErrorCode.REFRESH_TOKEN_NOT_FOUND); + } + + try { + // JWT 토큰 파싱 + String email = jwtUtil.getEmailFromToken(refreshToken); + String role = jwtUtil.getRoleFromToken(refreshToken); + + // 사용자 존재 확인 + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // Redis에서 리프레시 토큰 검증 + if (!refreshTokenService.validateRefreshToken(email, refreshToken)) { + throw new CustomException(ErrorCode.REFRESH_TOKEN_INVALID); + } + + // 새로운 토큰 발급 (RTR 방식) + String newAccessToken = jwtUtil.createAccessToken(email, role); + String newRefreshToken = jwtUtil.createRefreshToken(email, role); + + // 기존 리프레시 토큰 블랙리스트 추가 (보안 강화) + long remainingTime = jwtUtil.validateToken(refreshToken).getExpiration().getTime() - System.currentTimeMillis(); + if (remainingTime > 0) { + refreshTokenService.addToBlacklist(refreshToken, remainingTime); + } + + // Redis에 새로운 리프레시 토큰 저장 + refreshTokenService.saveRefreshToken(email, newRefreshToken); + + response.setHeader("Authorization", "Bearer " + newAccessToken); + cookieUtil.addRefreshTokenCookie(response, newRefreshToken); + + } catch (CustomException e) { + throw e; + } catch (Exception e) { + throw new CustomException(ErrorCode.REFRESH_TOKEN_INVALID); + } + } + + public void logout(HttpServletRequest request, HttpServletResponse response) { + String refreshToken = cookieUtil.getRefreshTokenFromCookie(request); + + if (refreshToken != null) { + try { + String email = jwtUtil.getEmailFromToken(refreshToken); + + // Redis에서 리프레시 토큰 삭제 + refreshTokenService.deleteRefreshToken(email); + + // 리프레시 토큰 블랙리스트 추가 + long remainingTime = jwtUtil.validateToken(refreshToken).getExpiration().getTime() - System.currentTimeMillis(); + if (remainingTime > 0) { + refreshTokenService.addToBlacklist(refreshToken, remainingTime); + } + } catch (Exception e) { + // 토큰이 유효하지 않아도 쿠키는 삭제 + } + } + + 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..e084779 --- /dev/null +++ b/oldYoung/src/main/java/com/app/oldYoung/global/security/service/RefreshTokenService.java @@ -0,0 +1,115 @@ +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; + + // 리프레시 토큰 저장 (RTR 방식) + 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("리프레시 토큰 저장에 실패했습니다."); + } + } + + // 리프레시 토큰 조회 및 검증 + 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; + } + } + + // 모든 사용자의 리프레시 토큰 삭제 (전체 로그아웃) + 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) { + return "blacklist:" + token.hashCode(); + } +} \ No newline at end of file From 43c7f6acecae6994f1ca0ed27ef18cf5ad23ae6c Mon Sep 17 00:00:00 2001 From: margie1a <4987kk@naver.com> Date: Tue, 19 Aug 2025 00:15:56 +0900 Subject: [PATCH 05/16] =?UTF-8?q?[FEAT]=20=EA=B2=BD=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EB=B3=80=EA=B2=BD=EC=82=AC=ED=95=AD=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entitlement/entity/Entitlement.java | 2 +- .../oldYoung/domain/harume/entity/Harume.java | 2 +- .../incomebracket/entity/IncomeBracket.java | 2 +- .../incomesnapshot/entity/IncomeSnapshot.java | 2 +- .../app/oldYoung/domain/user/entity/User.java | 21 +++++++++++++++- .../exception/CustomException.java | 0 .../apiResponse}/exception/ErrorCode.java | 0 .../exception/GlobalExceptionHandler.java | 0 .../apiResponse}/response/ApiResponse.java | 0 .../apiResponse}/response/SuccessCode.java | 0 .../global/{ => common}/config/JpaConfig.java | 0 .../common/config/RestTemplateConfig.java | 24 ------------------- .../controller/HealthCheckController.java | 0 .../common/{ => entity}/BaseEntity.java | 0 .../security/{ => config}/SecurityConfig.java | 0 15 files changed, 24 insertions(+), 29 deletions(-) rename oldYoung/src/main/java/com/app/oldYoung/global/{ => common/apiResponse}/exception/CustomException.java (100%) rename oldYoung/src/main/java/com/app/oldYoung/global/{ => common/apiResponse}/exception/ErrorCode.java (100%) rename oldYoung/src/main/java/com/app/oldYoung/global/{ => common/apiResponse}/exception/GlobalExceptionHandler.java (100%) rename oldYoung/src/main/java/com/app/oldYoung/global/{ => common/apiResponse}/response/ApiResponse.java (100%) rename oldYoung/src/main/java/com/app/oldYoung/global/{ => common/apiResponse}/response/SuccessCode.java (100%) rename oldYoung/src/main/java/com/app/oldYoung/global/{ => common}/config/JpaConfig.java (100%) delete mode 100644 oldYoung/src/main/java/com/app/oldYoung/global/common/config/RestTemplateConfig.java rename oldYoung/src/main/java/com/app/oldYoung/global/{ => common}/controller/HealthCheckController.java (100%) rename oldYoung/src/main/java/com/app/oldYoung/global/common/{ => entity}/BaseEntity.java (100%) rename oldYoung/src/main/java/com/app/oldYoung/global/security/{ => config}/SecurityConfig.java (100%) 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/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/global/exception/CustomException.java b/oldYoung/src/main/java/com/app/oldYoung/global/common/apiResponse/exception/CustomException.java similarity index 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 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 100% 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 diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/common/config/RestTemplateConfig.java b/oldYoung/src/main/java/com/app/oldYoung/global/common/config/RestTemplateConfig.java deleted file mode 100644 index 1b3b0a6..0000000 --- a/oldYoung/src/main/java/com/app/oldYoung/global/common/config/RestTemplateConfig.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.app.oldYoung.global.common.config; - -import java.util.ArrayList; -import java.util.List; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.converter.FormHttpMessageConverter; -import org.springframework.http.converter.HttpMessageConverter; -import org.springframework.web.client.RestTemplate; - -@Configuration -public class RestTemplateConfig { - - @Bean - public RestTemplate restTemplate() { - RestTemplate restTemplate = new RestTemplate(); - - List> messageConverters = new ArrayList<>(); - messageConverters.add(new FormHttpMessageConverter()); - restTemplate.setMessageConverters(messageConverters); - - return restTemplate; - } -} \ 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 100% 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 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 100% 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 diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/security/SecurityConfig.java b/oldYoung/src/main/java/com/app/oldYoung/global/security/config/SecurityConfig.java similarity index 100% rename from oldYoung/src/main/java/com/app/oldYoung/global/security/SecurityConfig.java rename to oldYoung/src/main/java/com/app/oldYoung/global/security/config/SecurityConfig.java From ffa8cbb2cdf64c846aa8e6b1738b68067bd770cd Mon Sep 17 00:00:00 2001 From: margie1a <4987kk@naver.com> Date: Tue, 19 Aug 2025 00:19:01 +0900 Subject: [PATCH 06/16] =?UTF-8?q?[CHORE]=20=EA=B2=BD=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=82=AC=ED=95=AD=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/CustomException.java | 2 +- .../apiResponse/exception/ErrorCode.java | 31 ++++++++++++------- .../exception/GlobalExceptionHandler.java | 6 ++-- .../apiResponse/response/ApiResponse.java | 2 +- .../apiResponse/response/SuccessCode.java | 2 +- .../controller/HealthCheckController.java | 4 +-- .../global/common/entity/BaseEntity.java | 2 +- 7 files changed, 29 insertions(+), 20 deletions(-) diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/common/apiResponse/exception/CustomException.java b/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/common/apiResponse/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/common/apiResponse/exception/ErrorCode.java b/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/common/apiResponse/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/common/apiResponse/exception/GlobalExceptionHandler.java b/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/common/apiResponse/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/common/apiResponse/response/ApiResponse.java b/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/common/apiResponse/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/common/apiResponse/response/SuccessCode.java b/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/common/apiResponse/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/common/controller/HealthCheckController.java b/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/common/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/entity/BaseEntity.java b/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/entity/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; From 0d0bcd98e9dfca02dadd125be0e0c5afc62158c2 Mon Sep 17 00:00:00 2001 From: margie1a <4987kk@naver.com> Date: Tue, 19 Aug 2025 00:20:10 +0900 Subject: [PATCH 07/16] =?UTF-8?q?[FEAT]=20=EB=A0=88=EB=94=94=EC=8A=A4,=20j?= =?UTF-8?q?pa,=20swagger=20config=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/app/oldYoung/global/common/config/JpaConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oldYoung/src/main/java/com/app/oldYoung/global/common/config/JpaConfig.java b/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/common/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; From 31492f5656ab47b05d14f6c009953031e65c4b00 Mon Sep 17 00:00:00 2001 From: margie1a <4987kk@naver.com> Date: Tue, 19 Aug 2025 00:20:48 +0900 Subject: [PATCH 08/16] =?UTF-8?q?[FEAT]=20OAuth2=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=8B=9C=ED=81=90=EB=A6=AC?= =?UTF-8?q?=ED=8B=B0=20config=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/config/SecurityConfig.java | 50 +++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) 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 index 7ad50fd..4fefccd 100644 --- 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 @@ -1,22 +1,66 @@ -package com.app.oldYoung.global.security; +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) - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); - + .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(); + } } From 9b3da3f0ac69a0d78ea22ab1ac21c641a29e14b2 Mon Sep 17 00:00:00 2001 From: margie1a <4987kk@naver.com> Date: Tue, 19 Aug 2025 00:21:30 +0900 Subject: [PATCH 09/16] =?UTF-8?q?[FEAT]=20KAKAO=EC=97=90=20=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20util=20=EB=B0=8F=20dto=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/security/util/KakaoUtil.java | 98 +++++++------------ 1 file changed, 35 insertions(+), 63 deletions(-) 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 index 2c9d346..c004096 100644 --- 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 @@ -3,90 +3,62 @@ 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 com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; +import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestTemplate; - -import java.util.Arrays; +import org.springframework.web.reactive.function.client.WebClient; @Component @Slf4j public class KakaoUtil { - @Value("${spring.kakao.auth.client}") + private final WebClient webClient; + + @Value("${spring.security.oauth2.client.registration.kakao.client-id}") private String client; - @Value("${spring.kakao.auth.redirect}") + + @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}") private String redirect; - public KakaoDTO.OAuthToken requestToken(String accessCode) { - RestTemplate restTemplate = new RestTemplate(); - HttpHeaders headers = new HttpHeaders(); - headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8"); + 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_url", redirect); + params.add("redirect_uri", redirect); params.add("code", accessCode); - HttpEntity> kakaoTokenRequest = new HttpEntity<>(params, - headers); - - ResponseEntity response = restTemplate.exchange( - "https://kauth.kakao.com/oauth/token", - HttpMethod.POST, - kakaoTokenRequest, - String.class); - - ObjectMapper objectMapper = new ObjectMapper(); - - KakaoDTO.OAuthToken oAuthToken = null; - - try { - oAuthToken = objectMapper.readValue(response.getBody(), KakaoDTO.OAuthToken.class); - log.info("oAuthToken : " + oAuthToken.getAccess_token()); - } catch (JsonProcessingException e) { - log.error("OAuth 토큰 파싱 실패: {}", e.getMessage()); - throw new AuthHandler(ErrorCode.PARSING_ERROR); - } - return oAuthToken; + 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(); } public KakaoDTO.KakaoProfile requestProfile(KakaoDTO.OAuthToken oAuthToken) { - RestTemplate restTemplate2 = new RestTemplate(); - HttpHeaders headers2 = new HttpHeaders(); - - headers2.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8"); - headers2.add("Authorization", "Bearer " + oAuthToken.getAccess_token()); - - HttpEntity> kakaoProfileRequest = new HttpEntity<>(headers2); - - ResponseEntity response2 = restTemplate2.exchange( - "https://kapi.kakao.com/v2/user/me", - HttpMethod.GET, - kakaoProfileRequest, - String.class); - - ObjectMapper objectMapper = new ObjectMapper(); - - KakaoDTO.KakaoProfile kakaoProfile = null; - - try { - kakaoProfile = objectMapper.readValue(response2.getBody(), KakaoDTO.KakaoProfile.class); - } catch (JsonProcessingException e) { - log.error("OAuth 프로필 파싱 실패: {}", e.getMessage()); - throw new AuthHandler(ErrorCode.PARSING_ERROR); - } - - return kakaoProfile; + return webClient.get() + .uri("https://kapi.kakao.com/v2/user/me") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + oAuthToken.getAccess_token()) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .retrieve() + .bodyToMono(KakaoDTO.KakaoProfile.class) + .doOnError(error -> { + log.error("OAuth 프로필 요청 실패: {}", error.getMessage()); + throw new AuthHandler(ErrorCode.OAUTH_PROFILE_REQUEST_FAILED); + }) + .block(); } -} \ No newline at end of file +} From 0579a643abee80ed15502854229c5636bcdaee1c Mon Sep 17 00:00:00 2001 From: margie1a <4987kk@naver.com> Date: Tue, 19 Aug 2025 00:22:41 +0900 Subject: [PATCH 10/16] =?UTF-8?q?[FEAT]=20JWT=20+=20OAuth2(Cookie)=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=EC=9D=84=20=EC=9C=84=ED=95=9C=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/security/service/AuthService.java | 106 +++++++----------- .../global/security/util/JwtUtil.java | 18 +-- 2 files changed, 50 insertions(+), 74 deletions(-) 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 index c0122fc..126f508 100644 --- 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 @@ -2,18 +2,19 @@ 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.converter.AuthConverter; import com.app.oldYoung.global.security.dto.KakaoDTO; import com.app.oldYoung.global.security.util.CookieUtil; import com.app.oldYoung.global.security.util.JwtUtil; import com.app.oldYoung.global.security.util.KakaoUtil; -import com.app.oldYoung.global.common.apiResponse.exception.CustomException; -import com.app.oldYoung.global.common.apiResponse.exception.ErrorCode; 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 @@ -26,108 +27,83 @@ public class AuthService { private final CookieUtil cookieUtil; private final RefreshTokenService refreshTokenService; + @Transactional public User oAuthLogin(String accessCode, HttpServletResponse httpServletResponse) { KakaoDTO.OAuthToken oAuthToken = kakaoUtil.requestToken(accessCode); KakaoDTO.KakaoProfile kakaoProfile = kakaoUtil.requestProfile(oAuthToken); - String providerId = String.valueOf(kakaoProfile.getId()); - User user = userRepository.findByProviderAndProviderId("kakao", providerId) - .orElseGet(() -> createNewUser(kakaoProfile)); + User user = processUser(kakaoProfile); String accessToken = jwtUtil.createAccessToken(user.getEmail(), "USER"); String refreshToken = jwtUtil.createRefreshToken(user.getEmail(), "USER"); - - // Redis에 리프레시 토큰 저장 + refreshTokenService.saveRefreshToken(user.getEmail(), refreshToken); - + httpServletResponse.setHeader("Authorization", "Bearer " + accessToken); cookieUtil.addRefreshTokenCookie(httpServletResponse, refreshToken); return user; } - private User createNewUser(KakaoDTO.KakaoProfile kakaoProfile) { + private User processUser(KakaoDTO.KakaoProfile kakaoProfile) { + String providerId = String.valueOf(kakaoProfile.getId()); + return userRepository.findByProviderAndProviderId("kakao", providerId) + .orElseGet(() -> createNewUser( + kakaoProfile.getKakao_account().getEmail(), + kakaoProfile.getKakao_account().getProfile().getNickname(), + providerId + )); + } + + private User createNewUser(String email, String nickname, String providerId) { User newUser = AuthConverter.toUser( - kakaoProfile.getKakao_account().getEmail(), - kakaoProfile.getKakao_account().getProfile().getNickname(), + email, + nickname, null, - String.valueOf(kakaoProfile.getId()), + providerId, passwordEncoder ); 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); } - try { - // JWT 토큰 파싱 - String email = jwtUtil.getEmailFromToken(refreshToken); - String role = jwtUtil.getRoleFromToken(refreshToken); - - // 사용자 존재 확인 - User user = userRepository.findByEmail(email) - .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - - // Redis에서 리프레시 토큰 검증 - if (!refreshTokenService.validateRefreshToken(email, refreshToken)) { - throw new CustomException(ErrorCode.REFRESH_TOKEN_INVALID); - } - - // 새로운 토큰 발급 (RTR 방식) - String newAccessToken = jwtUtil.createAccessToken(email, role); - String newRefreshToken = jwtUtil.createRefreshToken(email, role); - - // 기존 리프레시 토큰 블랙리스트 추가 (보안 강화) - long remainingTime = jwtUtil.validateToken(refreshToken).getExpiration().getTime() - System.currentTimeMillis(); - if (remainingTime > 0) { - refreshTokenService.addToBlacklist(refreshToken, remainingTime); - } - - // Redis에 새로운 리프레시 토큰 저장 - refreshTokenService.saveRefreshToken(email, newRefreshToken); - - response.setHeader("Authorization", "Bearer " + newAccessToken); - cookieUtil.addRefreshTokenCookie(response, newRefreshToken); - - } catch (CustomException e) { - throw e; - } catch (Exception e) { + String email = jwtUtil.getEmailFromToken(refreshToken); + + 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"); + + 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) { - try { - String email = jwtUtil.getEmailFromToken(refreshToken); - - // Redis에서 리프레시 토큰 삭제 - refreshTokenService.deleteRefreshToken(email); - - // 리프레시 토큰 블랙리스트 추가 - long remainingTime = jwtUtil.validateToken(refreshToken).getExpiration().getTime() - System.currentTimeMillis(); - if (remainingTime > 0) { - refreshTokenService.addToBlacklist(refreshToken, remainingTime); - } - } catch (Exception e) { - // 토큰이 유효하지 않아도 쿠키는 삭제 - } + 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/util/JwtUtil.java b/oldYoung/src/main/java/com/app/oldYoung/global/security/util/JwtUtil.java index e043317..72373f9 100644 --- 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 @@ -42,11 +42,11 @@ private String createToken(String email, String role, long expiration) { Date expiryDate = new Date(now.getTime() + expiration); return Jwts.builder() - .subject(email) + .setSubject(email) .claim("role", role) - .issuedAt(now) - .expiration(expiryDate) - .signWith(secretKey, Jwts.SIG.HS256) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(secretKey, SignatureAlgorithm.HS256) .compact(); } catch (Exception e) { log.error("JWT 토큰 생성 실패: {}", e.getMessage()); @@ -56,11 +56,11 @@ private String createToken(String email, String role, long expiration) { public Claims validateToken(String token) { try { - return Jwts.parser() - .verifyWith(secretKey) + return Jwts.parserBuilder() + .setSigningKey(secretKey) .build() - .parseSignedClaims(token) - .getPayload(); + .parseClaimsJws(token) + .getBody(); } catch (ExpiredJwtException e) { log.error("JWT 토큰이 만료되었습니다: {}", e.getMessage()); throw new AuthHandler(ErrorCode.JWT_TOKEN_EXPIRED); @@ -88,4 +88,4 @@ public boolean isTokenExpired(String token) { return true; } } -} \ No newline at end of file +} From 4f0f0fde2b28421b2397626c28fa027c703b7e4c Mon Sep 17 00:00:00 2001 From: margie1a <4987kk@naver.com> Date: Tue, 19 Aug 2025 00:25:02 +0900 Subject: [PATCH 11/16] =?UTF-8?q?[FEAT]=20OAuth2=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20yml=20=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98,=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 11 ++++++ oldYoung/build.gradle | 11 ++++++ .../user/controller/AuthController.java | 2 +- oldYoung/src/main/resources/application.yml | 34 ++++++++++++++++++- 4 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..28a925e --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(mkdir:*)", + "Bash(git mv:*)", + "Bash(find:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/oldYoung/build.gradle b/oldYoung/build.gradle index 4f32978..133261a 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,15 @@ dependencies { //DB runtimeOnly 'org.postgresql:postgresql' + testRuntimeOnly 'com.h2database:h2' + + //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/user/controller/AuthController.java b/oldYoung/src/main/java/com/app/oldYoung/domain/user/controller/AuthController.java index 850a539..1050b30 100644 --- 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 @@ -14,7 +14,7 @@ @RestController @RequiredArgsConstructor -@RequestMapping("") +@RequestMapping("/api") public class AuthController { private final AuthService authService; diff --git a/oldYoung/src/main/resources/application.yml b/oldYoung/src/main/resources/application.yml index 3d81fb2..bd3d518 100644 --- a/oldYoung/src/main/resources/application.yml +++ b/oldYoung/src/main/resources/application.yml @@ -14,4 +14,36 @@ 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: + - profile_nickname + - account_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} From 8671eaef5da93e27eea918d20cb8448124abd926 Mon Sep 17 00:00:00 2001 From: MinSoo Choi <162654709+Neo1228@users.noreply.github.com> Date: Tue, 19 Aug 2025 00:26:02 +0900 Subject: [PATCH 12/16] =?UTF-8?q?[CHORE]=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 28a925e..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(mkdir:*)", - "Bash(git mv:*)", - "Bash(find:*)" - ], - "deny": [], - "ask": [] - } -} \ No newline at end of file From 4b45a80a235a0bfcfef5af40920a8f47a4defe4a Mon Sep 17 00:00:00 2001 From: margie1a <4987kk@naver.com> Date: Tue, 19 Aug 2025 00:35:03 +0900 Subject: [PATCH 13/16] =?UTF-8?q?[CHORE]=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- oldYoung/build.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/oldYoung/build.gradle b/oldYoung/build.gradle index 133261a..2989f3d 100644 --- a/oldYoung/build.gradle +++ b/oldYoung/build.gradle @@ -42,7 +42,6 @@ dependencies { //DB runtimeOnly 'org.postgresql:postgresql' - testRuntimeOnly 'com.h2database:h2' //JWT implementation 'io.jsonwebtoken:jjwt-api:0.11.5' From c17461723720d81f83ef2606caeda4a1e35b755e Mon Sep 17 00:00:00 2001 From: margie1a <4987kk@naver.com> Date: Tue, 19 Aug 2025 01:28:57 +0900 Subject: [PATCH 14/16] =?UTF-8?q?[FEAT]=20kakao=20OIDC=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/AuthController.java | 17 ++- .../global/security/dto/KakaoDTO.java | 42 +------- .../global/security/service/AuthService.java | 54 ++++++---- .../global/security/util/JwtUtil.java | 102 +++++++++++++++++- .../global/security/util/KakaoUtil.java | 17 +-- oldYoung/src/main/resources/application.yml | 1 + 6 files changed, 155 insertions(+), 78 deletions(-) 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 index 1050b30..e270b09 100644 --- 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 @@ -19,11 +19,18 @@ public class AuthController { private final AuthService authService; - @GetMapping("/auth/login/kakao") - public ResponseEntity> kakaoLogin( - @RequestParam("code") String accessCode, - HttpServletResponse httpServletResponse) { - User user = authService.oAuthLogin(accessCode, httpServletResponse); + /** + * 소셜 로그인 통합 엔드포인트 + * @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)); } 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 index 7542d37..29a6021 100644 --- 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 @@ -15,43 +15,11 @@ public static class OAuthToken { private int expires_in; private String scope; private int refresh_token_expires_in; + private String id_token; } - @Getter - @JsonIgnoreProperties(ignoreUnknown = true) - public static class KakaoProfile { - - private Long id; - private String connected_at; - private Properties properties; - private KakaoAccount kakao_account; - - @Getter - @JsonIgnoreProperties(ignoreUnknown = true) - public class Properties { - - private String nickname; - } - - @Getter - @JsonIgnoreProperties(ignoreUnknown = true) - public class KakaoAccount { - - private String email; - private Boolean is_email_verified; - private Boolean has_email; - private Boolean profile_nickname_needs_agreement; - private Boolean email_needs_agreement; - private Boolean is_email_valid; - private Profile profile; - - @Getter - @JsonIgnoreProperties(ignoreUnknown = true) - public class Profile { - - private String nickname; - private Boolean is_default_nickname; - } - } - } + /** + * OIDC를 사용하면 ID Token에서 직접 프로필 정보를 얻으므로, + * 기존의 KakaoProfile 클래스는 더 이상 사용되지 않습니다. + */ } 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 index 126f508..035f306 100644 --- 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 @@ -4,11 +4,11 @@ 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.converter.AuthConverter; import com.app.oldYoung.global.security.dto.KakaoDTO; import com.app.oldYoung.global.security.util.CookieUtil; 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; @@ -28,12 +28,27 @@ public class AuthService { private final RefreshTokenService refreshTokenService; @Transactional - public User oAuthLogin(String accessCode, HttpServletResponse httpServletResponse) { - KakaoDTO.OAuthToken oAuthToken = kakaoUtil.requestToken(accessCode); - KakaoDTO.KakaoProfile kakaoProfile = kakaoUtil.requestProfile(oAuthToken); - - User user = processUser(kakaoProfile); + public User oAuthLogin(String provider, String accessCode, HttpServletResponse httpServletResponse) { + User user; + + if ("kakao".equalsIgnoreCase(provider)) { + // 1. 카카오로부터 토큰(Access Token + ID Token)을 받습니다. + KakaoDTO.OAuthToken oAuthToken = kakaoUtil.requestToken(accessCode); + String idToken = oAuthToken.getId_token(); + + // 2. JwtUtil을 통해 ID Token을 검증하고 사용자 정보를 추출합니다. + Claims claims = jwtUtil.validateAndGetClaimsFromKakaoToken(idToken); + String providerId = claims.getSubject(); // 'sub' 클레임 (사용자 고유 ID) + String email = claims.get("email", String.class); + String nickname = claims.get("nickname", String.class); + + // 3. 사용자 정보를 처리(조회 또는 생성)합니다. + user = processUser(provider, providerId, email, nickname); + } else { + throw new IllegalArgumentException("지원하지 않는 소셜 로그인 제공자입니다: " + provider); + } + // 4. 우리 서비스의 JWT(Access/Refresh Token)를 생성하고 응답에 담습니다. String accessToken = jwtUtil.createAccessToken(user.getEmail(), "USER"); String refreshToken = jwtUtil.createRefreshToken(user.getEmail(), "USER"); @@ -45,24 +60,19 @@ public User oAuthLogin(String accessCode, HttpServletResponse httpServletRespons return user; } - private User processUser(KakaoDTO.KakaoProfile kakaoProfile) { - String providerId = String.valueOf(kakaoProfile.getId()); - return userRepository.findByProviderAndProviderId("kakao", providerId) - .orElseGet(() -> createNewUser( - kakaoProfile.getKakao_account().getEmail(), - kakaoProfile.getKakao_account().getProfile().getNickname(), - providerId - )); + private User processUser(String provider, String providerId, String email, String nickname) { + return userRepository.findByProviderAndProviderId(provider, providerId) + .orElseGet(() -> createNewUser(provider, providerId, email, nickname)); } - private User createNewUser(String email, String nickname, String providerId) { - User newUser = AuthConverter.toUser( - email, - nickname, - null, - providerId, - passwordEncoder - ); + 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); } 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 index 72373f9..0865a89 100644 --- 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 @@ -2,14 +2,27 @@ 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 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.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.util.Date; +import org.springframework.web.client.RestTemplate; @Component @Slf4j @@ -18,14 +31,19 @@ public class JwtUtil { private final SecretKey secretKey; private final long accessTokenExpiration; private final long refreshTokenExpiration; + private final String kakaoClientId; + + private final Map kakaoPublicKeys = new ConcurrentHashMap<>(); public JwtUtil( @Value("${jwt.secret}") String secret, @Value("${jwt.access-token-expiration}") long accessTokenExpiration, - @Value("${jwt.refresh-token-expiration}") long refreshTokenExpiration) { + @Value("${jwt.refresh-token-expiration}") long refreshTokenExpiration, + @Value("${spring.security.oauth2.client.registration.kakao.client-id}") String kakaoClientId) { this.secretKey = Keys.hmacShaKeyFor(secret.getBytes()); this.accessTokenExpiration = accessTokenExpiration; this.refreshTokenExpiration = refreshTokenExpiration; + this.kakaoClientId = kakaoClientId; } public String createAccessToken(String email, String role) { @@ -54,6 +72,88 @@ private String createToken(String email, String role, long expiration) { } } + // [OIDC] 카카오 ID Token 검증 및 Claims 추출 메소드 + public Claims validateAndGetClaimsFromKakaoToken(String idToken) { + try { + // 1. 토큰 헤더에서 kid(Key ID) 추출 + String kid = getKidFromTokenHeader(idToken); + + // 2. kid에 해당하는 공개키 가져오기 (캐시 또는 API 호출) + PublicKey publicKey = getKakaoPublicKey(kid); + + // 3. 공개키를 사용하여 토큰 검증 + return Jwts.parserBuilder() + .setSigningKey(publicKey) + .requireIssuer("https://kauth.kakao.com") // iss가 카카오인지 확인 + .requireAudience(kakaoClientId) // 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); + } + } + + private String getKidFromTokenHeader(String token) { + try { + 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 e) { + log.error("ID Token 헤더 파싱 실패", e); + throw new AuthHandler(ErrorCode.JWT_TOKEN_INVALID); + } + } + + private PublicKey getKakaoPublicKey(String kid) { + // 캐시된 키가 있으면 바로 반환 + if (kakaoPublicKeys.containsKey(kid)) { + return kakaoPublicKeys.get(kid); + } + + // 캐시에 없으면 카카오 JWKS API에서 가져오기 + RestTemplate restTemplate = new RestTemplate(); + Map>> jwks = restTemplate.getForObject("https://kauth.kakao.com/.well-known/jwks.json", Map.class); + + PublicKey foundKey = null; + for (Map keyInfo : jwks.get("keys")) { + String currentKid = keyInfo.get("kid"); + // 모든 키를 캐시에 저장 + PublicKey publicKey = generatePublicKey(keyInfo); + kakaoPublicKeys.put(currentKid, publicKey); + if (kid.equals(currentKid)) { + foundKey = publicKey; + } + } + + if (foundKey == null) { + throw new AuthHandler(ErrorCode.JWT_TOKEN_INVALID); + } + + return foundKey; + } + + 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 AuthHandler(ErrorCode.JWT_TOKEN_INVALID); + } + } + public Claims validateToken(String token) { try { return Jwts.parserBuilder() 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 index c004096..1da3e50 100644 --- 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 @@ -48,17 +48,8 @@ public KakaoDTO.OAuthToken requestToken(String accessCode) { .block(); } - public KakaoDTO.KakaoProfile requestProfile(KakaoDTO.OAuthToken oAuthToken) { - return webClient.get() - .uri("https://kapi.kakao.com/v2/user/me") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + oAuthToken.getAccess_token()) - .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) - .retrieve() - .bodyToMono(KakaoDTO.KakaoProfile.class) - .doOnError(error -> { - log.error("OAuth 프로필 요청 실패: {}", error.getMessage()); - throw new AuthHandler(ErrorCode.OAUTH_PROFILE_REQUEST_FAILED); - }) - .block(); - } + /** + * requestProfile 메소드는 OIDC 흐름에서 더 이상 사용되지 않습니다. + * ID Token에서 직접 프로필 정보를 추출하기 때문입니다. + */ } diff --git a/oldYoung/src/main/resources/application.yml b/oldYoung/src/main/resources/application.yml index bd3d518..4ced469 100644 --- a/oldYoung/src/main/resources/application.yml +++ b/oldYoung/src/main/resources/application.yml @@ -27,6 +27,7 @@ spring: client-authentication-method: client_secret_post authorization-grant-type: authorization_code scope: + - openid - profile_nickname - account_email provider: From 167d5fde8c886c7c2a353cb299201c1a348a3020 Mon Sep 17 00:00:00 2001 From: margie1a <4987kk@naver.com> Date: Tue, 19 Aug 2025 02:27:53 +0900 Subject: [PATCH 15/16] =?UTF-8?q?[FEAT]=20=EA=B5=AC=EA=B8=80=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20with=20OIDC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/security/dto/GoogleDTO.java | 15 ++ .../global/security/service/AuthService.java | 62 +++--- .../global/security/util/GoogleUtil.java | 78 ++++++++ .../global/security/util/JwtUtil.java | 184 ++++++++++++------ oldYoung/src/main/resources/application.yml | 8 + 5 files changed, 261 insertions(+), 86 deletions(-) create mode 100644 oldYoung/src/main/java/com/app/oldYoung/global/security/dto/GoogleDTO.java create mode 100644 oldYoung/src/main/java/com/app/oldYoung/global/security/util/GoogleUtil.java 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/service/AuthService.java b/oldYoung/src/main/java/com/app/oldYoung/global/security/service/AuthService.java index 035f306..bf39644 100644 --- 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 @@ -4,8 +4,10 @@ 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; @@ -21,45 +23,69 @@ 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) { + public User oAuthLogin(String provider, String accessCode, + HttpServletResponse httpServletResponse) { User user; + Claims claims; + // 1. provider에 따라 분기 처리 if ("kakao".equalsIgnoreCase(provider)) { - // 1. 카카오로부터 토큰(Access Token + ID Token)을 받습니다. + // 1-1. 카카오 서버에 토큰 요청 KakaoDTO.OAuthToken oAuthToken = kakaoUtil.requestToken(accessCode); - String idToken = oAuthToken.getId_token(); + // 1-2. ID Token 검증 및 사용자 정보(Claims) 추출 + claims = jwtUtil.validateAndGetClaimsFromKakaoToken(oAuthToken.getId_token()); - // 2. JwtUtil을 통해 ID Token을 검증하고 사용자 정보를 추출합니다. - Claims claims = jwtUtil.validateAndGetClaimsFromKakaoToken(idToken); - String providerId = claims.getSubject(); // 'sub' 클레임 (사용자 고유 ID) - String email = claims.get("email", String.class); - String nickname = claims.get("nickname", String.class); + } else if ("google".equalsIgnoreCase(provider)) { + // 1-3. 구글 서버에 토큰 요청 + GoogleDTO oAuthToken = googleUtil.requestToken(accessCode); + // 1-4. ID Token 검증 및 사용자 정보(Claims) 추출 + claims = jwtUtil.validateAndGetClaimsFromGoogleToken(oAuthToken.getId_token()); - // 3. 사용자 정보를 처리(조회 또는 생성)합니다. - user = processUser(provider, providerId, email, nickname); } else { throw new IllegalArgumentException("지원하지 않는 소셜 로그인 제공자입니다: " + provider); } - // 4. 우리 서비스의 JWT(Access/Refresh Token)를 생성하고 응답에 담습니다. + // 2. 추출한 Claims에서 사용자 정보 파싱 + String providerId = claims.getSubject(); // OIDC 표준에서 사용자를 식별하는 고유 ID + String email = claims.get("email", String.class); + // 닉네임은 클레임 이름이 다를 수 있으므로 분기 처리 + 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) { return userRepository.findByProviderAndProviderId(provider, providerId) .orElseGet(() -> createNewUser(provider, providerId, email, nickname)); @@ -69,7 +95,7 @@ private User createNewUser(String provider, String providerId, String email, Str User newUser = User.builder() .email(email) .membername(nickname) - .password(null) // 소셜 로그인은 비밀번호 없음 + .password(null) .provider(provider) .providerId(providerId) .build(); @@ -82,33 +108,25 @@ public void reissueToken(HttpServletRequest request, HttpServletResponse respons if (refreshToken == null) { throw new CustomException(ErrorCode.REFRESH_TOKEN_NOT_FOUND); } - String email = jwtUtil.getEmailFromToken(refreshToken); - 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"); - 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); } @@ -116,4 +134,4 @@ 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/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 index 0865a89..f30fd95 100644 --- 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 @@ -1,11 +1,18 @@ 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; @@ -13,16 +20,10 @@ 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; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import javax.crypto.SecretKey; -import java.util.Date; -import org.springframework.web.client.RestTemplate; @Component @Slf4j @@ -32,60 +33,66 @@ public class JwtUtil { private final long accessTokenExpiration; private final long refreshTokenExpiration; private final String kakaoClientId; + private final String googleClientId; - private final Map kakaoPublicKeys = new ConcurrentHashMap<>(); + 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.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; } - 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) { + /** + * [OIDC] 구글 ID Token의 유효성을 검증하고, 토큰에 담긴 사용자 정보(Claims)를 반환합니다. + * + * @param idToken 구글로부터 받은 ID Token 문자열 + * @return 사용자 정보가 담긴 Claims 객체 + */ + public Claims validateAndGetClaimsFromGoogleToken(String idToken) { try { - Date now = new Date(); - Date expiryDate = new Date(now.getTime() + expiration); + String kid = getKidFromTokenHeader(idToken); + PublicKey publicKey = getPublicKey("google", kid); - 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); + 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 추출 메소드 + /** + * [OIDC] 카카오 ID Token의 유효성을 검증하고, 토큰에 담긴 사용자 정보(Claims)를 반환합니다. + * + * @param idToken 카카오로부터 받은 ID Token 문자열 + * @return 사용자 정보가 담긴 Claims 객체 + */ public Claims validateAndGetClaimsFromKakaoToken(String idToken) { try { - // 1. 토큰 헤더에서 kid(Key ID) 추출 String kid = getKidFromTokenHeader(idToken); + PublicKey publicKey = getPublicKey("kakao", kid); - // 2. kid에 해당하는 공개키 가져오기 (캐시 또는 API 호출) - PublicKey publicKey = getKakaoPublicKey(kid); - - // 3. 공개키를 사용하여 토큰 검증 return Jwts.parserBuilder() .setSigningKey(publicKey) - .requireIssuer("https://kauth.kakao.com") // iss가 카카오인지 확인 - .requireAudience(kakaoClientId) // aud가 우리 앱 ID인지 확인 + .requireIssuer("https://kauth.kakao.com") + .requireAudience(kakaoClientId) .build() .parseClaimsJws(idToken) .getBody(); @@ -98,46 +105,53 @@ public Claims validateAndGetClaimsFromKakaoToken(String idToken) { } } - private String getKidFromTokenHeader(String token) { - try { - 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 e) { - log.error("ID Token 헤더 파싱 실패", e); - 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; + if (publicKeys.containsKey(cacheKey)) { + return publicKeys.get(cacheKey); } - } - private PublicKey getKakaoPublicKey(String kid) { - // 캐시된 키가 있으면 바로 반환 - if (kakaoPublicKeys.containsKey(kid)) { - return kakaoPublicKeys.get(kid); + 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입니다."); } - // 캐시에 없으면 카카오 JWKS API에서 가져오기 RestTemplate restTemplate = new RestTemplate(); - Map>> jwks = restTemplate.getForObject("https://kauth.kakao.com/.well-known/jwks.json", Map.class); + Map>> jwks = restTemplate.getForObject(jwksUri, Map.class); PublicKey foundKey = null; - for (Map keyInfo : jwks.get("keys")) { - String currentKid = keyInfo.get("kid"); - // 모든 키를 캐시에 저장 - PublicKey publicKey = generatePublicKey(keyInfo); - kakaoPublicKeys.put(currentKid, publicKey); - if (kid.equals(currentKid)) { - foundKey = publicKey; + if (jwks != null && jwks.get("keys") != null) { + 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 AuthHandler(ErrorCode.JWT_TOKEN_INVALID); + 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")); @@ -150,10 +164,52 @@ private PublicKey generatePublicKey(Map keyInfo) { 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 { + 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() @@ -188,4 +244,4 @@ public boolean isTokenExpired(String token) { return true; } } -} +} \ No newline at end of file diff --git a/oldYoung/src/main/resources/application.yml b/oldYoung/src/main/resources/application.yml index 4ced469..396660b 100644 --- a/oldYoung/src/main/resources/application.yml +++ b/oldYoung/src/main/resources/application.yml @@ -30,6 +30,14 @@ spring: - 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 From 2c2b15cdf737133ac9bf54253f9ef2be95236b8a Mon Sep 17 00:00:00 2001 From: margie1a <4987kk@naver.com> Date: Tue, 19 Aug 2025 02:51:15 +0900 Subject: [PATCH 16/16] =?UTF-8?q?[CHORE]=20=EB=A1=9C=EC=A7=81=20=EC=84=A4?= =?UTF-8?q?=EB=AA=85=20=EC=A3=BC=EC=84=9D=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/security/filter/JwtAuthenticationFilter.java | 4 ++-- .../app/oldYoung/global/security/service/AuthService.java | 5 ++++- .../global/security/service/RefreshTokenService.java | 7 ++++--- .../com/app/oldYoung/global/security/util/JwtUtil.java | 3 +++ 4 files changed, 13 insertions(+), 6 deletions(-) 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 index ecff6aa..9ab3491 100644 --- 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 @@ -35,7 +35,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse String jwt = getJwtFromRequest(request); if (StringUtils.hasText(jwt)) { - // 토큰 블랙리스트 확인 + // 1. 로그아웃된 토큰이 다시 사용되는 것을 방지하기 위해 블랙리스트 체크 if (refreshTokenService.isTokenBlacklisted(jwt)) { log.warn("블랙리스트된 토큰 사용 시도"); filterChain.doFilter(request, response); @@ -45,7 +45,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse // JWT에서 이메일 추출 String email = jwtUtil.getEmailFromToken(jwt); - // 이미 인증된 사용자가 아닌 경우에만 처리 + // 2. 중복 인증 방지: 이미 SecurityContext에 인증 정보가 있으면 건너뛴 if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = customUserDetailsService.loadUserByUsername(email); 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 index bf39644..698ae8b 100644 --- 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 @@ -65,7 +65,7 @@ public User oAuthLogin(String provider, String accessCode, // 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); @@ -87,6 +87,7 @@ public User oAuthLogin(String provider, String accessCode, * 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)); } @@ -109,6 +110,7 @@ public void reissueToken(HttpServletRequest request, HttpServletResponse respons 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); } @@ -116,6 +118,7 @@ public void reissueToken(HttpServletRequest request, HttpServletResponse respons .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); 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 index e084779..7e5741b 100644 --- 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 @@ -18,7 +18,7 @@ public class RefreshTokenService { @Value("${jwt.refresh-token-expiration}") private long refreshTokenExpiration; - // 리프레시 토큰 저장 (RTR 방식) + // 1. RTR(Refresh Token Rotation) 방식으로 토큰 새로 저장 시 기존 토큰 자동 무효화 public void saveRefreshToken(String email, String refreshToken) { String key = getRefreshTokenKey(email); try { @@ -35,7 +35,7 @@ public void saveRefreshToken(String email, String refreshToken) { } } - // 리프레시 토큰 조회 및 검증 + // 2. Redis에 저장된 토큰과 요청 토큰의 일치 여부 검증 public boolean validateRefreshToken(String email, String refreshToken) { String key = getRefreshTokenKey(email); try { @@ -91,7 +91,7 @@ public boolean isTokenBlacklisted(String token) { } } - // 모든 사용자의 리프레시 토큰 삭제 (전체 로그아웃) + // 3. 모든 디바이스에서 로그아웃 처리 (패턴 매칭으로 복수 토큰 삭제) public void deleteAllRefreshTokens(String email) { String pattern = "refresh_token:" + email + "*"; try { @@ -110,6 +110,7 @@ private String getRefreshTokenKey(String 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/JwtUtil.java b/oldYoung/src/main/java/com/app/oldYoung/global/security/util/JwtUtil.java index f30fd95..b989d3d 100644 --- 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 @@ -115,6 +115,7 @@ public Claims validateAndGetClaimsFromKakaoToken(String idToken) { */ private PublicKey getPublicKey(String provider, String kid) { String cacheKey = provider + "_" + kid; + // 1. 네트워크 요청 최소화를 위해 공개키 메모리 캐싱 체크 if (publicKeys.containsKey(cacheKey)) { return publicKeys.get(cacheKey); } @@ -133,6 +134,7 @@ private PublicKey getPublicKey(String provider, String kid) { 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); @@ -173,6 +175,7 @@ private PublicKey generatePublicKey(Map keyInfo) { */ 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),