From d1ac69713dde13045285a867634966c5faca6467 Mon Sep 17 00:00:00 2001 From: seungwookc97 Date: Thu, 18 Sep 2025 16:36:54 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat=20:=20secure=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=A1=9C=EC=BB=AC=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9x,=20p?= =?UTF-8?q?rod=20yml=EC=83=9D=EC=84=B1=ED=95=98=EC=97=AC=20true=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=A0=84=ED=99=98.?= =?UTF-8?q?=20=EB=A1=9C=EA=B9=85=20dev.yml=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/back/global/rq/Rq.java | 6 +++++- src/main/resources/application-dev.yml | 13 ++++++++++++ src/main/resources/application-prod.yml | 27 ++++++++++++++++++++++++ src/main/resources/application.yml | 7 ------ 4 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 src/main/resources/application-prod.yml diff --git a/src/main/java/com/back/global/rq/Rq.java b/src/main/java/com/back/global/rq/Rq.java index db4abc93..ba09f564 100644 --- a/src/main/java/com/back/global/rq/Rq.java +++ b/src/main/java/com/back/global/rq/Rq.java @@ -8,6 +8,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseCookie; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; @@ -24,6 +25,9 @@ public class Rq { private final HttpServletResponse resp; private final UserService userService; + @Value("${custom.cookie.secure:false}") + private boolean cookieSecure; + public User getActor() { return Optional.ofNullable( SecurityContextHolder @@ -85,7 +89,7 @@ public void setCrossDomainCookie(String name, String value, int maxAge) { ResponseCookie cookie = ResponseCookie.from(name, value) .path("/") .maxAge(maxAge) - .secure(true) + .secure(cookieSecure) .sameSite("None") .httpOnly(true) .build(); diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index a1d1a3f5..875f3ea1 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -30,6 +30,19 @@ springdoc: path: /swagger-ui.html operationsSorter: method +# 개발용 상세 로깅 +logging: + level: + org.hibernate.orm.jdbc.bind: TRACE + org.hibernate.orm.jdbc.extract: TRACE + org.springframework.transaction.interceptor: TRACE + com.back: DEBUG + +# 쿠키 보안 설정 (HTTP 환경용) +custom: + cookie: + secure: false + # # AI 설정 # ai: # openai: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 00000000..c5444ca5 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,27 @@ +# 프로덕션 환경 설정 +#spring: +# datasource: +# url: ${DATABASE_URL} +# +# +# jpa: +# hibernate: +# ddl-auto: update +# properties: +# hibernate: +# show_sql: false + + +springdoc: + swagger-ui: + enabled: false + +logging: + level: + com.back: INFO + root: WARN + +# 쿠키 보안 설정 +custom: + cookie: + secure: true \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 44fcb5e0..d1f8aa56 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -40,13 +40,6 @@ spring: springdoc: default-produces-media-type: application/json;charset=UTF-8 -logging: - level: - org.hibernate.orm.jdbc.bind: TRACE - org.hibernate.orm.jdbc.extract: TRACE - org.springframework.transaction.interceptor: TRACE - com.back: DEBUG - server: address: 0.0.0.0 port: 8080 From cf5aafd08f3465ff40c3c3881877c4458df7faa5 Mon Sep 17 00:00:00 2001 From: seungwookc97 Date: Thu, 18 Sep 2025 17:59:07 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat=20:=20OAuth=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EC=9C=A0=EC=A0=80=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=801?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/back/domain/user/entity/User.java | 7 ++ .../user/repository/UserRepository.java | 5 ++ .../back/domain/user/service/UserService.java | 68 +++++++++++++++++ .../security/CustomAuthenticationFilter.java | 5 +- ...tomOAuth2AuthorizationRequestResolver.java | 62 ++++++++++++++++ .../CustomOAuth2LoginFailureHandler.java | 32 ++++++++ .../CustomOAuth2LoginSuccessHandler.java | 43 +++++++++++ .../security/CustomOAuth2UserService.java | 73 +++++++++++++++++++ .../back/global/security/SecurityConfig.java | 17 +++++ .../back/global/security/SecurityUser.java | 17 ++++- src/main/resources/application-dev.yml | 4 + src/main/resources/application-prod.yml | 19 ++++- src/main/resources/application.yml | 6 +- 13 files changed, 348 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/back/global/security/CustomOAuth2AuthorizationRequestResolver.java create mode 100644 src/main/java/com/back/global/security/CustomOAuth2LoginFailureHandler.java create mode 100644 src/main/java/com/back/global/security/CustomOAuth2LoginSuccessHandler.java create mode 100644 src/main/java/com/back/global/security/CustomOAuth2UserService.java diff --git a/src/main/java/com/back/domain/user/entity/User.java b/src/main/java/com/back/domain/user/entity/User.java index d1d64476..5078f375 100644 --- a/src/main/java/com/back/domain/user/entity/User.java +++ b/src/main/java/com/back/domain/user/entity/User.java @@ -31,6 +31,13 @@ public class User { @Column(nullable = false, unique = true, length = 50) private String nickname; // 고유 닉네임 + // OAuth2 관련 필드 + @Column(unique = true, length = 100) + private String oauthId; // OAuth 제공자별 고유 ID (예: kakao_123456789) + + @Column(length = 20) + private String provider; // OAuth 제공자 (KAKAO, GOOGLE, NAVER) + private Double abvDegree; // 알콜도수(회원 등급) private LocalDateTime createdAt; // 생성 날짜 diff --git a/src/main/java/com/back/domain/user/repository/UserRepository.java b/src/main/java/com/back/domain/user/repository/UserRepository.java index ce4bcc8a..dbdeb157 100644 --- a/src/main/java/com/back/domain/user/repository/UserRepository.java +++ b/src/main/java/com/back/domain/user/repository/UserRepository.java @@ -4,7 +4,12 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface UserRepository extends JpaRepository { + Optional findByOauthId(String oauthId); + Optional findByEmail(String email); + Optional findByNickname(String nickname); } diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index 103c5a97..79e50072 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -2,10 +2,15 @@ import com.back.domain.user.entity.User; import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.ServiceException; +import com.back.global.rsData.RsData; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.Optional; + @Service @RequiredArgsConstructor public class UserService { @@ -17,4 +22,67 @@ public User findById(Long id) { return userRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("User not found. id=" + id)); } + + // 소셜로그인으로 회원가입 & 회원 정보 수정 + public RsData modifyOrJoin(String oauthId, String email, String nickname) { + + //oauthId로 기존 회원인지 확인 + User User = userRepository.findByOauthId(oauthId).orElse(null); + + // 기존 회원이 아니면 소셜로그인으로 회원가입 진행 + if(User == null) { + User = joinSocial(oauthId, email, nickname); + return new RsData<>(201, "회원가입이 완료되었습니다.", User); + } + + // 기존 회원이면 회원 정보 수정 + modifySocial(User, nickname); + return new RsData<>(200, "회원 정보가 수정되었습니다.", User); + } + + public User joinSocial(String oauthId, String email, String nickname){ + userRepository.findByOauthId(oauthId) + .ifPresent(user -> { + throw new ServiceException(409, "이미 존재하는 계정입니다."); + }); + + User user = User.builder() + .email(email) + .profileImgUrl(null) + .abvDegree(0.0) + .role("USER") //기본 권한 USER. 관리자면 "ADMIN"으로 설정하시면 됩니다 + .oauthId(oauthId) + .build(); + + return userRepository.save(user); + } + + public void modifySocial(User user, String nickname){ + user.setNickname(nickname); + userRepository.save(user); + } + + public RsData findOrCreateOAuthUser(String oauthId, String email, String nickname, String provider) { + Optional existingUser = userRepository.findByOauthId(oauthId); + + if (existingUser.isPresent()) { + // 기존 사용자 업데이트 (이메일, 닉네임 변경 가능) + User user = existingUser.get(); + user.setEmail(email); + user.setNickname(nickname); + return RsData.of(200, "기존 사용자 정보 업데이트", userRepository.save(user)); + } else { + // 새 사용자 생성 + User newUser = User.builder() + .oauthId(oauthId) + .email(email) + .nickname(nickname) + .provider(provider) + .role("USER") + .createdAt(LocalDateTime.now()) + .build(); + return RsData.of(201, "새 사용자 생성", userRepository.save(newUser)); + } + } + } \ No newline at end of file diff --git a/src/main/java/com/back/global/security/CustomAuthenticationFilter.java b/src/main/java/com/back/global/security/CustomAuthenticationFilter.java index 621230d1..b3aa11f3 100644 --- a/src/main/java/com/back/global/security/CustomAuthenticationFilter.java +++ b/src/main/java/com/back/global/security/CustomAuthenticationFilter.java @@ -19,6 +19,7 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.Map; @Component @RequiredArgsConstructor @@ -120,8 +121,8 @@ private void work(HttpServletRequest request, HttpServletResponse response, Filt user.getId(), user.getEmail(), user.getNickname(), - "", - user.getAuthorities() + user.getAuthorities(), + Map.of() // JWT 인증에서는 빈 attributes ); Authentication authentication = new UsernamePasswordAuthenticationToken( userDetails, diff --git a/src/main/java/com/back/global/security/CustomOAuth2AuthorizationRequestResolver.java b/src/main/java/com/back/global/security/CustomOAuth2AuthorizationRequestResolver.java new file mode 100644 index 00000000..944b6707 --- /dev/null +++ b/src/main/java/com/back/global/security/CustomOAuth2AuthorizationRequestResolver.java @@ -0,0 +1,62 @@ +package com.back.global.security; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class CustomOAuth2AuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver { + + private final ClientRegistrationRepository clientRegistrationRepository; + + private DefaultOAuth2AuthorizationRequestResolver createDefaultResolver() { + // Spring Security 기본 Authorization URI 사용 + return new DefaultOAuth2AuthorizationRequestResolver( + clientRegistrationRepository, + OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI + ); + } + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { + OAuth2AuthorizationRequest req = createDefaultResolver().resolve(request); + return customizeState(req, request); + } + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) { + OAuth2AuthorizationRequest req = createDefaultResolver().resolve(request, clientRegistrationId); + return customizeState(req, request); + } + + private OAuth2AuthorizationRequest customizeState(OAuth2AuthorizationRequest req, HttpServletRequest request) { + if (req == null) return null; + + // 요청 파라미터에서 redirectUrl 가져오기 + String redirectUrl = request.getParameter("redirectUrl"); + if (redirectUrl == null) redirectUrl = "/"; + + // CSRF 방지용 nonce 추가 + String originState = UUID.randomUUID().toString(); + + // redirectUrl#originState 결합 + String rawState = redirectUrl + "#" + originState; + + // Base64 URL-safe 인코딩 + String encodedState = Base64.getUrlEncoder().encodeToString(rawState.getBytes(StandardCharsets.UTF_8)); + + return OAuth2AuthorizationRequest.from(req) + .state(encodedState) // state 교체 + .build(); + } +} diff --git a/src/main/java/com/back/global/security/CustomOAuth2LoginFailureHandler.java b/src/main/java/com/back/global/security/CustomOAuth2LoginFailureHandler.java new file mode 100644 index 00000000..395380a2 --- /dev/null +++ b/src/main/java/com/back/global/security/CustomOAuth2LoginFailureHandler.java @@ -0,0 +1,32 @@ +package com.back.global.security; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@Slf4j +public class CustomOAuth2LoginFailureHandler implements AuthenticationFailureHandler { + + @Value("${FRONTEND_URL}") + private String frontendUrl; + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + + log.error("OAuth2 로그인 실패: {}", exception.getMessage()); + + // 프론트엔드 에러 페이지로 리다이렉트 + String redirectUrl = frontendUrl + "/oauth/error?message=" + exception.getMessage(); + + response.sendRedirect(redirectUrl); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/security/CustomOAuth2LoginSuccessHandler.java b/src/main/java/com/back/global/security/CustomOAuth2LoginSuccessHandler.java new file mode 100644 index 00000000..107671a9 --- /dev/null +++ b/src/main/java/com/back/global/security/CustomOAuth2LoginSuccessHandler.java @@ -0,0 +1,43 @@ +package com.back.global.security; + +import com.back.domain.user.service.UserService; +import com.back.global.jwt.JwtUtil; +import com.back.global.rq.Rq; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +@Component +@RequiredArgsConstructor +public class CustomOAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { + private final Rq rq; + private final JwtUtil jwtUtil; + private final UserService userService; + + @Value("${FRONTEND_URL}") + private String frontendUrl; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + SecurityUser securityUser = (SecurityUser) authentication.getPrincipal(); + + // Access Token 생성 + String accessToken = jwtUtil.generateAccessToken(securityUser.getId(), securityUser.getEmail()); + + // 쿠키에 토큰 저장 + rq.setCrossDomainCookie("accessToken", accessToken, (int) TimeUnit.MINUTES.toSeconds(20)); + + // 프론트엔드로 리다이렉트 + String redirectUrl = frontendUrl + "/oauth/success"; + + response.sendRedirect(redirectUrl); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/security/CustomOAuth2UserService.java b/src/main/java/com/back/global/security/CustomOAuth2UserService.java new file mode 100644 index 00000000..d1cdee3c --- /dev/null +++ b/src/main/java/com/back/global/security/CustomOAuth2UserService.java @@ -0,0 +1,73 @@ +package com.back.global.security; + +import com.back.domain.user.entity.User; +import com.back.domain.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + private final UserService userService; + + // OAuth2 로그인 성공 시 자동 호출 + @Override + @Transactional + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + + OAuth2User oAuth2User = super.loadUser(userRequest); + + String oauthUserId = ""; + String providerTypeCode = userRequest.getClientRegistration().getRegistrationId().toUpperCase(); + String nickname = ""; + String email = ""; + + switch (providerTypeCode) { + case "KAKAO" -> { + Map attributes = oAuth2User.getAttributes(); + Map attributesProperties = (Map) attributes.get("properties"); + Map kakaoAccount = (Map) attributes.get("kakao_account"); + + oauthUserId = oAuth2User.getName(); + nickname = (String) attributesProperties.get("nickname"); + email = (String) kakaoAccount.get("email"); + } + case "GOOGLE" -> { + oauthUserId = oAuth2User.getName(); + nickname = (String) oAuth2User.getAttributes().get("name"); + email = (String) oAuth2User.getAttributes().get("email"); + } + case "NAVER" -> { + Map attributes = oAuth2User.getAttributes(); + Map attributesProperties = (Map) attributes.get("response"); + + oauthUserId = (String) attributesProperties.get("id"); + nickname = (String) attributesProperties.get("nickname"); + email = (String) attributesProperties.get("email"); + } + } + + // OAuth ID를 제공자와 함께 저장 (예: kakao_123456789) + String uniqueOauthId = providerTypeCode.toLowerCase() + "_" + oauthUserId; + + User user = userService.findOrCreateOAuthUser(uniqueOauthId, email, nickname, providerTypeCode).data(); + + // securityContext + return new SecurityUser( + user.getId(), + user.getEmail(), + user.getNickname(), + user.getAuthorities(), + oAuth2User.getAttributes() + ); + } +} diff --git a/src/main/java/com/back/global/security/SecurityConfig.java b/src/main/java/com/back/global/security/SecurityConfig.java index d3063b10..a49d97e7 100644 --- a/src/main/java/com/back/global/security/SecurityConfig.java +++ b/src/main/java/com/back/global/security/SecurityConfig.java @@ -18,6 +18,18 @@ @EnableWebSecurity public class SecurityConfig { + private final CustomOAuth2UserService customOAuth2UserService; + private final CustomOAuth2LoginSuccessHandler oauth2SuccessHandler; + private final CustomOAuth2LoginFailureHandler oauth2FailureHandler; + + public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, + CustomOAuth2LoginSuccessHandler oauth2SuccessHandler, + CustomOAuth2LoginFailureHandler oauth2FailureHandler) { + this.customOAuth2UserService = customOAuth2UserService; + this.oauth2SuccessHandler = oauth2SuccessHandler; + this.oauth2FailureHandler = oauth2FailureHandler; + } + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http @@ -38,6 +50,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .redirectionEndpoint(redirection -> redirection .baseUri("/api/login/oauth2/code/*") ) + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService) + ) + .successHandler(oauth2SuccessHandler) + .failureHandler(oauth2FailureHandler) ) .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)); diff --git a/src/main/java/com/back/global/security/SecurityUser.java b/src/main/java/com/back/global/security/SecurityUser.java index b9e210f5..25aa02b6 100644 --- a/src/main/java/com/back/global/security/SecurityUser.java +++ b/src/main/java/com/back/global/security/SecurityUser.java @@ -18,21 +18,30 @@ public class SecurityUser extends User implements OAuth2User { @Getter private String email; + private Map attributes; + + // OAuth2 전용 생성자 (패스워드 없음) public SecurityUser( long id, String email, String name, - String password, - Collection authorities + Collection authorities, + Map attributes ) { - super(email, password , authorities); + super(email, "", authorities); // OAuth2에서는 빈 패스워드 this.id = id; this.name = name; this.email = email; + this.attributes = attributes; } @Override public Map getAttributes() { - return Map.of(); + return attributes; + } + + @Override + public String getName() { + return name; // OAuth2User 인터페이스용 } } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 875f3ea1..d8f8c3bf 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -22,6 +22,10 @@ spring: format_sql: true show_sql: true +# 개발 환경 URL 설정 +FRONTEND_URL: http://localhost:3000 +BASE_URL: http://localhost:8080 + # Swagger 설정 springdoc: api-docs: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index c5444ca5..db3ab288 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -24,4 +24,21 @@ logging: # 쿠키 보안 설정 custom: cookie: - secure: true \ No newline at end of file + secure: true + +# OAuth2 배포 환경 설정 +spring: + security: + oauth2: + client: + registration: + kakao: + redirect-uri: '${BASE_URL}/api/{action}/oauth2/code/{registrationId}' + google: + redirect-uri: '${BASE_URL}/api/{action}/oauth2/code/{registrationId}' + naver: + redirect-uri: '${BASE_URL}/api/{action}/oauth2/code/{registrationId}' + +# 프로덕션 환경 URL 설정 +BASE_URL: ${BASE_URL} +FRONTEND_URL: ${FRONTEND_URL} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d1f8aa56..642b3799 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,11 +11,11 @@ spring: scope: profile_nickname # 카카오 닉네임만 가져옴 client-name: Kakao authorization-grant-type: authorization_code - redirect-uri: '{baseUrl}/api/{action}/oauth2/code/{registrationId}' + redirect-uri: '${BASE_URL}/api/{action}/oauth2/code/{registrationId}' google: client-id: ${GOOGLE_OAUTH2_CLIENT_ID} client-secret: ${GOOGLE_OAUTH2_CLIENT_SECRET} - redirect-uri: '{baseUrl}/api/{action}/oauth2/code/{registrationId}' + redirect-uri: '${BASE_URL}/api/{action}/oauth2/code/{registrationId}' client-name: Google scope: profile naver: @@ -24,7 +24,7 @@ spring: scope: profile_nickname # 네이버 닉네임만 가져옴 client-name: Naver authorization-grant-type: authorization_code - redirect-uri: '{baseUrl}/api/{action}/oauth2/code/{registrationId}' + redirect-uri: '${BASE_URL}/api/{action}/oauth2/code/{registrationId}' provider: kakao: authorization-uri: https://kauth.kakao.com/oauth/authorize From 1ae6022cb5a518d7644be4c2498f296ec584c870 Mon Sep 17 00:00:00 2001 From: seungwookc97 Date: Fri, 19 Sep 2025 09:29:24 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat=20:=20Security=20Config=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20OAuth=20=EA=B5=AC=ED=98=84#2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 3 +- src/main/java/com/back/BackApplication.java | 6 ++++ .../back/domain/user/service/UserService.java | 34 ++++++++++++++---- .../security/CustomAuthenticationFilter.java | 6 ++-- .../security/CustomOAuth2UserService.java | 18 +++++++--- .../back/global/security/SecurityConfig.java | 36 +++++++++++++++---- src/main/resources/application.yml | 9 +++-- 7 files changed, 88 insertions(+), 24 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 5dc03649..73890c8a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,14 +28,13 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-web") - + implementation("io.github.cdimascio:java-dotenv:5.2.2") implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-oauth2-client") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0") implementation("io.jsonwebtoken:jjwt-api:0.12.3") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.3") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.3") - implementation("me.paulschwarz:spring-dotenv:4.0.0") compileOnly("org.projectlombok:lombok") developmentOnly("org.springframework.boot:spring-boot-devtools") runtimeOnly("com.h2database:h2") diff --git a/src/main/java/com/back/BackApplication.java b/src/main/java/com/back/BackApplication.java index 530c84fc..87ac32bb 100644 --- a/src/main/java/com/back/BackApplication.java +++ b/src/main/java/com/back/BackApplication.java @@ -1,5 +1,6 @@ package com.back; +import io.github.cdimascio.dotenv.Dotenv; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; @@ -9,6 +10,11 @@ public class BackApplication { public static void main(String[] args) { + Dotenv dotenv = Dotenv.load(); + System.out.println("KAKAO_OAUTH2_CLIENT_ID: " + dotenv.get("KAKAO_OAUTH2_CLIENT_ID")); + System.out.println("GOOGLE_OAUTH2_CLIENT_ID: " + dotenv.get("GOOGLE_OAUTH2_CLIENT_ID")); + System.out.println("NAVER_OAUTH2_CLIENT_ID: " + dotenv.get("NAVER_OAUTH2_CLIENT_ID")); + SpringApplication.run(BackApplication.class, args); } diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index 79e50072..692e15ab 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -23,7 +23,7 @@ public User findById(Long id) { .orElseThrow(() -> new IllegalArgumentException("User not found. id=" + id)); } - // 소셜로그인으로 회원가입 & 회원 정보 수정 + // 소셜로그인으로 회원가입 & 회원 정보 수정 public RsData modifyOrJoin(String oauthId, String email, String nickname) { //oauthId로 기존 회원인지 확인 @@ -46,11 +46,15 @@ public User joinSocial(String oauthId, String email, String nickname){ throw new ServiceException(409, "이미 존재하는 계정입니다."); }); + // 고유한 닉네임 생성 + String uniqueNickname = generateUniqueNickname(nickname); + User user = User.builder() .email(email) + .nickname(uniqueNickname) .profileImgUrl(null) .abvDegree(0.0) - .role("USER") //기본 권한 USER. 관리자면 "ADMIN"으로 설정하시면 됩니다 + .role("USER") .oauthId(oauthId) .build(); @@ -66,17 +70,17 @@ public RsData findOrCreateOAuthUser(String oauthId, String email, String n Optional existingUser = userRepository.findByOauthId(oauthId); if (existingUser.isPresent()) { - // 기존 사용자 업데이트 (이메일, 닉네임 변경 가능) + // 기존 사용자 업데이트 (이메일만 업데이트) User user = existingUser.get(); user.setEmail(email); - user.setNickname(nickname); return RsData.of(200, "기존 사용자 정보 업데이트", userRepository.save(user)); } else { - // 새 사용자 생성 + // 새 사용자 생성 - 고유한 닉네임 생성 + String uniqueNickname = generateUniqueNickname(nickname); User newUser = User.builder() .oauthId(oauthId) .email(email) - .nickname(nickname) + .nickname(uniqueNickname) .provider(provider) .role("USER") .createdAt(LocalDateTime.now()) @@ -85,4 +89,22 @@ public RsData findOrCreateOAuthUser(String oauthId, String email, String n } } + private String generateUniqueNickname(String baseNickname) { + // null이거나 빈 문자열인 경우 기본값 설정 + if (baseNickname == null || baseNickname.trim().isEmpty()) { + baseNickname = "User"; + } + + String nickname = baseNickname; + int counter = 1; + + // 중복 체크 및 고유한 닉네임 생성 + while (userRepository.findByNickname(nickname).isPresent()) { + nickname = baseNickname + counter; + counter++; + } + + return nickname; + } + } \ No newline at end of file diff --git a/src/main/java/com/back/global/security/CustomAuthenticationFilter.java b/src/main/java/com/back/global/security/CustomAuthenticationFilter.java index b3aa11f3..a79a7907 100644 --- a/src/main/java/com/back/global/security/CustomAuthenticationFilter.java +++ b/src/main/java/com/back/global/security/CustomAuthenticationFilter.java @@ -55,9 +55,9 @@ private void work(HttpServletRequest request, HttpServletResponse response, Filt if ( //추후 로그인 필요한 api 추가 설정 uri.startsWith("/h2-console") || - uri.startsWith("/api/login/oauth2/") || - (method.equals("GET") && uri.equals("api/~~")) || - (method.equals("POST") && uri.equals("/api/user/login")) + uri.startsWith("/login/oauth2/") || + (method.equals("GET") && uri.equals("/api/~~")) || + (method.equals("POST") && uri.equals("/api/~")) ) { filterChain.doFilter(request, response); diff --git a/src/main/java/com/back/global/security/CustomOAuth2UserService.java b/src/main/java/com/back/global/security/CustomOAuth2UserService.java index d1cdee3c..0e7a8344 100644 --- a/src/main/java/com/back/global/security/CustomOAuth2UserService.java +++ b/src/main/java/com/back/global/security/CustomOAuth2UserService.java @@ -35,11 +35,9 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic case "KAKAO" -> { Map attributes = oAuth2User.getAttributes(); Map attributesProperties = (Map) attributes.get("properties"); - Map kakaoAccount = (Map) attributes.get("kakao_account"); oauthUserId = oAuth2User.getName(); nickname = (String) attributesProperties.get("nickname"); - email = (String) kakaoAccount.get("email"); } case "GOOGLE" -> { oauthUserId = oAuth2User.getName(); @@ -59,13 +57,25 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic // OAuth ID를 제공자와 함께 저장 (예: kakao_123456789) String uniqueOauthId = providerTypeCode.toLowerCase() + "_" + oauthUserId; + log.debug("OAuth2 user info - oauthUserId: {}, email: {}, nickname: {}, provider: {}", + oauthUserId, email, nickname, providerTypeCode); + User user = userService.findOrCreateOAuthUser(uniqueOauthId, email, nickname, providerTypeCode).data(); + log.debug("User from DB - id: {}, email: {}, nickname: {}", + user.getId(), user.getEmail(), user.getNickname()); + + // null 체크 및 기본값 설정 + String userEmail = user.getEmail() != null && !user.getEmail().trim().isEmpty() + ? user.getEmail() : "unknown@example.com"; + String userNickname = user.getNickname() != null && !user.getNickname().trim().isEmpty() + ? user.getNickname() : "Unknown User"; + // securityContext return new SecurityUser( user.getId(), - user.getEmail(), - user.getNickname(), + userEmail, + userNickname, user.getAuthorities(), oAuth2User.getAttributes() ); diff --git a/src/main/java/com/back/global/security/SecurityConfig.java b/src/main/java/com/back/global/security/SecurityConfig.java index a49d97e7..dc469757 100644 --- a/src/main/java/com/back/global/security/SecurityConfig.java +++ b/src/main/java/com/back/global/security/SecurityConfig.java @@ -21,13 +21,16 @@ public class SecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; private final CustomOAuth2LoginSuccessHandler oauth2SuccessHandler; private final CustomOAuth2LoginFailureHandler oauth2FailureHandler; + private final CustomOAuth2AuthorizationRequestResolver customOAuth2AuthorizationRequestResolver; public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, CustomOAuth2LoginSuccessHandler oauth2SuccessHandler, - CustomOAuth2LoginFailureHandler oauth2FailureHandler) { + CustomOAuth2LoginFailureHandler oauth2FailureHandler, + CustomOAuth2AuthorizationRequestResolver customOAuth2AuthorizationRequestResolver) { this.customOAuth2UserService = customOAuth2UserService; this.oauth2SuccessHandler = oauth2SuccessHandler; this.oauth2FailureHandler = oauth2FailureHandler; + this.customOAuth2AuthorizationRequestResolver = customOAuth2AuthorizationRequestResolver; } @Bean @@ -37,18 +40,27 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .cors(cors -> cors.configurationSource(corsConfigurationSource())) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/","/api/**").permitAll() + .requestMatchers("/").permitAll() .requestMatchers("/h2-console/**").permitAll() .requestMatchers("/oauth2/**").permitAll() + .requestMatchers("/login/oauth2/**").permitAll() .requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll() + .requestMatchers("/api/user/**").permitAll() + .requestMatchers("/api/cocktail/**").permitAll() + + + // 회원 or 인증된 사용자만 가능 + .requestMatchers("/api/admin/**").hasRole("ADMIN") +// .requestMatchers("/api/cocktail/detail~~").authenticated() + + //그 외에는 인증해야함 .anyRequest().authenticated() ) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) .oauth2Login(oauth2 -> oauth2 .authorizationEndpoint(authorization -> authorization - .baseUri("/api/oauth2/authorization") - ) - .redirectionEndpoint(redirection -> redirection - .baseUri("/api/login/oauth2/code/*") + .authorizationRequestResolver(customOAuth2AuthorizationRequestResolver) ) .userInfoEndpoint(userInfo -> userInfo .userService(customOAuth2UserService) @@ -56,6 +68,18 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .successHandler(oauth2SuccessHandler) .failureHandler(oauth2FailureHandler) ) + .exceptionHandling(exceptionHandling -> exceptionHandling + .authenticationEntryPoint((request, response, authException) -> { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(401); + response.getWriter().write("{\"code\":401,\"message\":\"로그인 후 이용해주세요.\"}"); + }) + .accessDeniedHandler((request, response, accessDeniedException) -> { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(403); + response.getWriter().write("{\"code\":403,\"message\":\"권한이 없습니다.\"}"); + }) + ) .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)); return http.build(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 642b3799..cfcc301e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,6 +2,9 @@ spring: profiles: active: dev + config: + import: optional:file:.env[.properties] + security: oauth2: client: @@ -11,11 +14,11 @@ spring: scope: profile_nickname # 카카오 닉네임만 가져옴 client-name: Kakao authorization-grant-type: authorization_code - redirect-uri: '${BASE_URL}/api/{action}/oauth2/code/{registrationId}' + redirect-uri: '${BASE_URL}/{action}/oauth2/code/{registrationId}' google: client-id: ${GOOGLE_OAUTH2_CLIENT_ID} client-secret: ${GOOGLE_OAUTH2_CLIENT_SECRET} - redirect-uri: '${BASE_URL}/api/{action}/oauth2/code/{registrationId}' + redirect-uri: '${BASE_URL}/{action}/oauth2/code/{registrationId}' client-name: Google scope: profile naver: @@ -24,7 +27,7 @@ spring: scope: profile_nickname # 네이버 닉네임만 가져옴 client-name: Naver authorization-grant-type: authorization_code - redirect-uri: '${BASE_URL}/api/{action}/oauth2/code/{registrationId}' + redirect-uri: '${BASE_URL}/{action}/oauth2/code/{registrationId}' provider: kakao: authorization-uri: https://kauth.kakao.com/oauth/authorize From f25806a307aba10397a8cfbae1c8faa41c24f63f Mon Sep 17 00:00:00 2001 From: seungwookc97 Date: Fri, 19 Sep 2025 10:38:28 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor=20:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/back/domain/user/entity/User.java | 3 -- .../back/domain/user/service/UserService.java | 44 ++++--------------- .../security/CustomOAuth2UserService.java | 21 ++++----- 3 files changed, 16 insertions(+), 52 deletions(-) diff --git a/src/main/java/com/back/domain/user/entity/User.java b/src/main/java/com/back/domain/user/entity/User.java index 665b0076..4e8c5d43 100644 --- a/src/main/java/com/back/domain/user/entity/User.java +++ b/src/main/java/com/back/domain/user/entity/User.java @@ -37,9 +37,6 @@ public class User { @Column(unique = true, length = 100) private String oauthId; // OAuth 제공자별 고유 ID (예: kakao_123456789) - @Column(length = 20) - private String provider; // OAuth 제공자 (KAKAO, GOOGLE, NAVER) - private Double abvDegree; // 알콜도수(회원 등급) @CreatedDate // JPA Auditing 적용 diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index 692e15ab..3555a62e 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -23,22 +23,6 @@ public User findById(Long id) { .orElseThrow(() -> new IllegalArgumentException("User not found. id=" + id)); } - // 소셜로그인으로 회원가입 & 회원 정보 수정 - public RsData modifyOrJoin(String oauthId, String email, String nickname) { - - //oauthId로 기존 회원인지 확인 - User User = userRepository.findByOauthId(oauthId).orElse(null); - - // 기존 회원이 아니면 소셜로그인으로 회원가입 진행 - if(User == null) { - User = joinSocial(oauthId, email, nickname); - return new RsData<>(201, "회원가입이 완료되었습니다.", User); - } - - // 기존 회원이면 회원 정보 수정 - modifySocial(User, nickname); - return new RsData<>(200, "회원 정보가 수정되었습니다.", User); - } public User joinSocial(String oauthId, String email, String nickname){ userRepository.findByOauthId(oauthId) @@ -52,8 +36,9 @@ public User joinSocial(String oauthId, String email, String nickname){ User user = User.builder() .email(email) .nickname(uniqueNickname) - .profileImgUrl(null) .abvDegree(0.0) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) .role("USER") .oauthId(oauthId) .build(); @@ -61,35 +46,22 @@ public User joinSocial(String oauthId, String email, String nickname){ return userRepository.save(user); } - public void modifySocial(User user, String nickname){ - user.setNickname(nickname); - userRepository.save(user); - } - - public RsData findOrCreateOAuthUser(String oauthId, String email, String nickname, String provider) { + @Transactional + public RsData findOrCreateOAuthUser(String oauthId, String email, String nickname) { Optional existingUser = userRepository.findByOauthId(oauthId); if (existingUser.isPresent()) { // 기존 사용자 업데이트 (이메일만 업데이트) User user = existingUser.get(); user.setEmail(email); - return RsData.of(200, "기존 사용자 정보 업데이트", userRepository.save(user)); + return RsData.of(200, "회원 정보가 업데이트 되었습니다", user); //더티체킹 } else { - // 새 사용자 생성 - 고유한 닉네임 생성 - String uniqueNickname = generateUniqueNickname(nickname); - User newUser = User.builder() - .oauthId(oauthId) - .email(email) - .nickname(uniqueNickname) - .provider(provider) - .role("USER") - .createdAt(LocalDateTime.now()) - .build(); - return RsData.of(201, "새 사용자 생성", userRepository.save(newUser)); + User newUser = joinSocial(oauthId, email, nickname); + return RsData.of(201, "사용자가 생성되었습니다", newUser); } } - private String generateUniqueNickname(String baseNickname) { + public String generateUniqueNickname(String baseNickname) { // null이거나 빈 문자열인 경우 기본값 설정 if (baseNickname == null || baseNickname.trim().isEmpty()) { baseNickname = "User"; diff --git a/src/main/java/com/back/global/security/CustomOAuth2UserService.java b/src/main/java/com/back/global/security/CustomOAuth2UserService.java index 0e7a8344..3d35f4ce 100644 --- a/src/main/java/com/back/global/security/CustomOAuth2UserService.java +++ b/src/main/java/com/back/global/security/CustomOAuth2UserService.java @@ -2,8 +2,8 @@ import com.back.domain.user.entity.User; import com.back.domain.user.service.UserService; +import com.back.global.rsData.RsData; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; @@ -15,7 +15,6 @@ @Service @RequiredArgsConstructor -@Slf4j public class CustomOAuth2UserService extends DefaultOAuth2UserService { private final UserService userService; @@ -56,26 +55,22 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic // OAuth ID를 제공자와 함께 저장 (예: kakao_123456789) String uniqueOauthId = providerTypeCode.toLowerCase() + "_" + oauthUserId; + RsData rsData = userService.findOrCreateOAuthUser(uniqueOauthId, email, nickname); - log.debug("OAuth2 user info - oauthUserId: {}, email: {}, nickname: {}, provider: {}", - oauthUserId, email, nickname, providerTypeCode); - - User user = userService.findOrCreateOAuthUser(uniqueOauthId, email, nickname, providerTypeCode).data(); + if (rsData.code()<200 || rsData.code()>299) { + throw new OAuth2AuthenticationException("사용자 생성/조회 실패: " + rsData.message()); + } - log.debug("User from DB - id: {}, email: {}, nickname: {}", - user.getId(), user.getEmail(), user.getNickname()); + User user = rsData.data(); - // null 체크 및 기본값 설정 String userEmail = user.getEmail() != null && !user.getEmail().trim().isEmpty() - ? user.getEmail() : "unknown@example.com"; - String userNickname = user.getNickname() != null && !user.getNickname().trim().isEmpty() - ? user.getNickname() : "Unknown User"; + ? user.getEmail() : "unknown"; // securityContext return new SecurityUser( user.getId(), userEmail, - userNickname, + user.getNickname(), user.getAuthorities(), oAuth2User.getAttributes() );