From a603ddd2b2416aaf8d228b647cd832932ee867b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EC=88=98?= Date: Tue, 27 Aug 2024 17:52:28 +0900 Subject: [PATCH 1/2] =?UTF-8?q?Feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20-=20S11P21S002-15?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/attribute/OAuth2Attribute.java | 22 ++++++++ .../attribute/OAuth2AttributeFactory.java | 20 +++++++ .../auth/attribute/impl/GoogleAttribute.java | 34 ++++++++++++ .../auth/controller/AuthController.java | 19 +++++++ .../auth/service/CustomOAuth2UserService.java | 52 +++++++++++++++++++ .../worlabel/domain/member/entity/Member.java | 21 ++++++++ .../member/repository/MemberRepository.java | 10 ++++ .../global/config/SecurityConfig.java | 38 ++++++++++++++ backend/src/main/resources/.gitkeep | 0 9 files changed, 216 insertions(+) create mode 100644 backend/src/main/java/com/worlabel/domain/auth/attribute/OAuth2Attribute.java create mode 100644 backend/src/main/java/com/worlabel/domain/auth/attribute/OAuth2AttributeFactory.java create mode 100644 backend/src/main/java/com/worlabel/domain/auth/attribute/impl/GoogleAttribute.java create mode 100644 backend/src/main/java/com/worlabel/domain/auth/controller/AuthController.java create mode 100644 backend/src/main/java/com/worlabel/domain/auth/service/CustomOAuth2UserService.java create mode 100644 backend/src/main/java/com/worlabel/domain/member/repository/MemberRepository.java create mode 100644 backend/src/main/java/com/worlabel/global/config/SecurityConfig.java create mode 100644 backend/src/main/resources/.gitkeep diff --git a/backend/src/main/java/com/worlabel/domain/auth/attribute/OAuth2Attribute.java b/backend/src/main/java/com/worlabel/domain/auth/attribute/OAuth2Attribute.java new file mode 100644 index 0000000..d2f52f1 --- /dev/null +++ b/backend/src/main/java/com/worlabel/domain/auth/attribute/OAuth2Attribute.java @@ -0,0 +1,22 @@ +package com.worlabel.domain.auth.attribute; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +import java.util.Map; + +@Getter +@ToString +@RequiredArgsConstructor +public abstract class OAuth2Attribute { + private final Map attributes; + + public abstract String getId(); + + public abstract String getName(); + + public abstract String getEmail(); + + public abstract String getProfileImage(); +} diff --git a/backend/src/main/java/com/worlabel/domain/auth/attribute/OAuth2AttributeFactory.java b/backend/src/main/java/com/worlabel/domain/auth/attribute/OAuth2AttributeFactory.java new file mode 100644 index 0000000..a245879 --- /dev/null +++ b/backend/src/main/java/com/worlabel/domain/auth/attribute/OAuth2AttributeFactory.java @@ -0,0 +1,20 @@ +package com.worlabel.domain.auth.attribute; + +import com.worlabel.domain.auth.attribute.impl.GoogleAttribute; +import com.worlabel.domain.auth.entity.ProviderType; + +import java.util.Map; + +public class OAuth2AttributeFactory { + public static OAuth2Attribute parseAttribute(ProviderType provider, Map attributes){ + OAuth2Attribute oAuth2Attribute = null; + switch (provider) { + case GOOGLE : + oAuth2Attribute = new GoogleAttribute(attributes); + break; + default: + throw new RuntimeException("지원하지 않는 소셜 로그인입니다."); + }; + return oAuth2Attribute; + } +} diff --git a/backend/src/main/java/com/worlabel/domain/auth/attribute/impl/GoogleAttribute.java b/backend/src/main/java/com/worlabel/domain/auth/attribute/impl/GoogleAttribute.java new file mode 100644 index 0000000..f922e00 --- /dev/null +++ b/backend/src/main/java/com/worlabel/domain/auth/attribute/impl/GoogleAttribute.java @@ -0,0 +1,34 @@ +package com.worlabel.domain.auth.attribute.impl; + +import ch.qos.logback.core.util.StringUtil; +import com.worlabel.domain.auth.attribute.OAuth2Attribute; +import org.springframework.util.StringUtils; + +import java.util.Map; + +public class GoogleAttribute extends OAuth2Attribute { + + public GoogleAttribute(Map attributes) { + super(attributes); + } + + @Override + public String getId() { + return super.getAttributes().get("sub").toString(); + } + + @Override + public String getName() { + return super.getAttributes().get("name").toString(); + } + + @Override + public String getEmail() { + return super.getAttributes().get("email").toString(); + } + + @Override + public String getProfileImage() { + return super.getAttributes().get("picture").toString(); + } +} diff --git a/backend/src/main/java/com/worlabel/domain/auth/controller/AuthController.java b/backend/src/main/java/com/worlabel/domain/auth/controller/AuthController.java new file mode 100644 index 0000000..0984596 --- /dev/null +++ b/backend/src/main/java/com/worlabel/domain/auth/controller/AuthController.java @@ -0,0 +1,19 @@ +package com.worlabel.domain.auth.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/auth") +public class AuthController { + + @GetMapping("/login") + public String login() { + return "성공"; + } +} diff --git a/backend/src/main/java/com/worlabel/domain/auth/service/CustomOAuth2UserService.java b/backend/src/main/java/com/worlabel/domain/auth/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..ba71b6a --- /dev/null +++ b/backend/src/main/java/com/worlabel/domain/auth/service/CustomOAuth2UserService.java @@ -0,0 +1,52 @@ +package com.worlabel.domain.auth.service; + +import com.worlabel.domain.auth.attribute.OAuth2Attribute; +import com.worlabel.domain.auth.attribute.OAuth2AttributeFactory; +import com.worlabel.domain.auth.entity.ProviderType; +import com.worlabel.domain.member.entity.Member; +import com.worlabel.domain.member.repository.MemberRepository; +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.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2UserAuthority; +import org.springframework.stereotype.Service; + +import java.util.Collections; + +/** + * OAuth2 사용자 서비스 클래스 + * OAuth2 인증을 통해 사용자 정보를 로드하고 처리하는 역할 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final MemberRepository memberRepository; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User user = super.loadUser(userRequest); + log.debug("oAuth2User: {}", user.getAttributes()); + + ProviderType provider = ProviderType.valueOf(userRequest.getClientRegistration().getRegistrationId().toUpperCase()); + OAuth2Attribute attribute = OAuth2AttributeFactory.parseAttribute(provider, user.getAttributes()); + log.debug("provider: {}, user: {}", provider, user); + + Member findMember = memberRepository.findByProviderMemberId(attribute.getId()) + .orElseGet(() -> { + Member member = Member.of(attribute.getId(), attribute.getEmail(), attribute.getName(), attribute.getProfileImage()); + memberRepository.save(member); + return member; + }); + + return new DefaultOAuth2User(Collections.singleton(new OAuth2UserAuthority(user.getAttributes())), + user.getAttributes(), + "sub" + ); + } +} diff --git a/backend/src/main/java/com/worlabel/domain/member/entity/Member.java b/backend/src/main/java/com/worlabel/domain/member/entity/Member.java index 4e09ed7..aed0952 100644 --- a/backend/src/main/java/com/worlabel/domain/member/entity/Member.java +++ b/backend/src/main/java/com/worlabel/domain/member/entity/Member.java @@ -38,6 +38,18 @@ public class Member extends BaseEntity { @Enumerated(EnumType.STRING) private ProviderType provider = ProviderType.GOOGLE; + /** + * 사용자 이메일 + */ + @Column(name = "email",nullable = false, length = 40) + private String email; + + /** + * 사용자 닉네임 + */ + @Column(name = "nickname",nullable = false, length = 20) + private String nickname; + /** * 사용자 역할 */ @@ -56,4 +68,13 @@ public class Member extends BaseEntity { */ @OneToMany(mappedBy = "member", fetch = FetchType.LAZY,cascade = CascadeType.ALL, orphanRemoval = true) private List commentList = new ArrayList<>(); + + public static Member of(String providerMemberId, String email, String nickname, String profileImage) { + Member member = new Member(); + member.providerMemberId = providerMemberId; + member.email = email; + member.nickname = nickname; + member.profileImage = profileImage; + return member; + } } diff --git a/backend/src/main/java/com/worlabel/domain/member/repository/MemberRepository.java b/backend/src/main/java/com/worlabel/domain/member/repository/MemberRepository.java new file mode 100644 index 0000000..bbdb8b5 --- /dev/null +++ b/backend/src/main/java/com/worlabel/domain/member/repository/MemberRepository.java @@ -0,0 +1,10 @@ +package com.worlabel.domain.member.repository; + +import com.worlabel.domain.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + public Optional findByProviderMemberId(String providerMemberId); +} diff --git a/backend/src/main/java/com/worlabel/global/config/SecurityConfig.java b/backend/src/main/java/com/worlabel/global/config/SecurityConfig.java new file mode 100644 index 0000000..209e26c --- /dev/null +++ b/backend/src/main/java/com/worlabel/global/config/SecurityConfig.java @@ -0,0 +1,38 @@ +package com.worlabel.global.config; + +import lombok.Builder; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; + +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + // HTTP 요청에 대한 인증 및 권한 설정 + .authorizeHttpRequests(auth -> auth + // 경로는 모든 사용자가 접근 할 수 있도록 허용 + .requestMatchers("/**","/favicon.ico").permitAll() + // 그 외의 모든 요청은 인증해야함 + .anyRequest().authenticated() + ) + // OAuth2 로그인 설정 + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> + // 사용자 정보를 처리하는 서비스로 DefaultOAuth2UserService + userInfo.userService(new DefaultOAuth2UserService())) + ) + // CSRF 보호 설정 + .csrf(csrf -> csrf // CSRF 보호 활성화 및 커스터마이징 + // CSRF토큰을 쿠키에 저장하도록 설정, HTTP-only 속성을 비활성화하여 자바스크립트에서 접근 가능하게 함 + .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + ); + // 설정을 바탕으로 SecurityFilterChain 빌드 + return http.build(); + } +} diff --git a/backend/src/main/resources/.gitkeep b/backend/src/main/resources/.gitkeep new file mode 100644 index 0000000..e69de29 From b7cc09745cef32d3739f0fb67a910ff75a445ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EC=88=98?= Date: Wed, 28 Aug 2024 00:55:50 +0900 Subject: [PATCH 2/2] =?UTF-8?q?Feat:=20@CurrentUser=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/build.gradle | 16 ++- .../auth/controller/AuthController.java | 11 +- .../domain/auth/dto/AuthMemberDto.java | 22 ++++ .../auth/entity/CustomOAuth2Member.java | 44 ++++++++ ...ce.java => CustomOAuth2MemberService.java} | 21 ++-- .../domain/auth/service/JwtTokenService.java | 9 ++ .../member/repository/MemberRepository.java | 4 +- .../global/advice/CustomControllerAdvice.java | 17 ++- .../global/annotation/CurrentUser.java | 12 +++ .../worlabel/global/config/CorsMvcConfig.java | 21 ++++ .../global/config/SecurityConfig.java | 102 ++++++++++++++---- .../com/worlabel/global/config/WebConfig.java | 21 ++++ .../worlabel/global/exception/ErrorCode.java | 2 +- .../com/worlabel/global/filter/JWTFilter.java | 53 +++++++++ .../global/handler/OAuth2SuccessHandler.java | 64 +++++++++++ .../resolver/CurrentUserArgumentResolver.java | 32 ++++++ .../com/worlabel/global/util/JWTUtil.java | 51 +++++++++ backend/src/main/resources/application.yml | 53 +++++++++ 18 files changed, 510 insertions(+), 45 deletions(-) create mode 100644 backend/src/main/java/com/worlabel/domain/auth/dto/AuthMemberDto.java create mode 100644 backend/src/main/java/com/worlabel/domain/auth/entity/CustomOAuth2Member.java rename backend/src/main/java/com/worlabel/domain/auth/service/{CustomOAuth2UserService.java => CustomOAuth2MemberService.java} (68%) create mode 100644 backend/src/main/java/com/worlabel/domain/auth/service/JwtTokenService.java create mode 100644 backend/src/main/java/com/worlabel/global/annotation/CurrentUser.java create mode 100644 backend/src/main/java/com/worlabel/global/config/CorsMvcConfig.java create mode 100644 backend/src/main/java/com/worlabel/global/config/WebConfig.java create mode 100644 backend/src/main/java/com/worlabel/global/filter/JWTFilter.java create mode 100644 backend/src/main/java/com/worlabel/global/handler/OAuth2SuccessHandler.java create mode 100644 backend/src/main/java/com/worlabel/global/resolver/CurrentUserArgumentResolver.java create mode 100644 backend/src/main/java/com/worlabel/global/util/JWTUtil.java create mode 100644 backend/src/main/resources/application.yml diff --git a/backend/build.gradle b/backend/build.gradle index 05a9fae..129e100 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -25,10 +25,14 @@ repositories { dependencies { // Spring Boot - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' + // Spring Data JPA + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + // Lombok compileOnly 'org.projectlombok:lombok' @@ -43,6 +47,14 @@ dependencies { // GJson implementation 'com.google.code.gson:gson:2.7' + + // OAuth + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' } tasks.named('test') { diff --git a/backend/src/main/java/com/worlabel/domain/auth/controller/AuthController.java b/backend/src/main/java/com/worlabel/domain/auth/controller/AuthController.java index 0984596..802feb7 100644 --- a/backend/src/main/java/com/worlabel/domain/auth/controller/AuthController.java +++ b/backend/src/main/java/com/worlabel/domain/auth/controller/AuthController.java @@ -1,5 +1,8 @@ package com.worlabel.domain.auth.controller; +import com.worlabel.domain.auth.dto.AuthMemberDto; +import com.worlabel.global.annotation.CurrentUser; +import com.worlabel.global.response.SuccessResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; @@ -12,8 +15,10 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/auth") public class AuthController { - @GetMapping("/login") - public String login() { - return "성공"; + // TODO: 리이슈 처리 + + @GetMapping("/user-info") + public SuccessResponse getMemberInfo(@CurrentUser AuthMemberDto currentMember){ + return SuccessResponse.of(currentMember); } } diff --git a/backend/src/main/java/com/worlabel/domain/auth/dto/AuthMemberDto.java b/backend/src/main/java/com/worlabel/domain/auth/dto/AuthMemberDto.java new file mode 100644 index 0000000..4b81590 --- /dev/null +++ b/backend/src/main/java/com/worlabel/domain/auth/dto/AuthMemberDto.java @@ -0,0 +1,22 @@ +package com.worlabel.domain.auth.dto; + +import com.worlabel.domain.member.entity.Member; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class AuthMemberDto { + private int id; + private String email; + private String role; + + public static AuthMemberDto of(Member member) { + AuthMemberDto authMemberDto = new AuthMemberDto(); + authMemberDto.id = member.getId(); + authMemberDto.email = member.getEmail(); + authMemberDto.role = member.getRole().toString(); + + return authMemberDto; + } +} diff --git a/backend/src/main/java/com/worlabel/domain/auth/entity/CustomOAuth2Member.java b/backend/src/main/java/com/worlabel/domain/auth/entity/CustomOAuth2Member.java new file mode 100644 index 0000000..d48235e --- /dev/null +++ b/backend/src/main/java/com/worlabel/domain/auth/entity/CustomOAuth2Member.java @@ -0,0 +1,44 @@ +package com.worlabel.domain.auth.entity; + +import com.worlabel.domain.auth.dto.AuthMemberDto; +import com.worlabel.domain.member.entity.Member; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +public class CustomOAuth2Member implements OAuth2User { + + @Getter + private final AuthMemberDto authMemberDto; + + public CustomOAuth2Member(Member member) { + authMemberDto = AuthMemberDto.of(member); + } + + @Override + public Map getAttributes() { + // OAuth2 제공자로부터 받은 사용자 속성 데이터를 반환합니다. + return Map.of( + "id", authMemberDto.getId(), + "email", authMemberDto.getEmail(), + "role", authMemberDto.getRole() + ); + } + + @Override + public Collection getAuthorities() { + // 사용자의 역할(RoleType)을 권한으로 변환하여 반환합니다. + return List.of(new SimpleGrantedAuthority(authMemberDto.getRole())); + } + + @Override + public String getName() { + // 사용자의 고유 식별자를 반환합니다. 여기서는 이메일을 사용합니다. + return authMemberDto.getEmail(); + } +} diff --git a/backend/src/main/java/com/worlabel/domain/auth/service/CustomOAuth2UserService.java b/backend/src/main/java/com/worlabel/domain/auth/service/CustomOAuth2MemberService.java similarity index 68% rename from backend/src/main/java/com/worlabel/domain/auth/service/CustomOAuth2UserService.java rename to backend/src/main/java/com/worlabel/domain/auth/service/CustomOAuth2MemberService.java index ba71b6a..427313b 100644 --- a/backend/src/main/java/com/worlabel/domain/auth/service/CustomOAuth2UserService.java +++ b/backend/src/main/java/com/worlabel/domain/auth/service/CustomOAuth2MemberService.java @@ -2,6 +2,7 @@ package com.worlabel.domain.auth.service; import com.worlabel.domain.auth.attribute.OAuth2Attribute; import com.worlabel.domain.auth.attribute.OAuth2AttributeFactory; +import com.worlabel.domain.auth.entity.CustomOAuth2Member; import com.worlabel.domain.auth.entity.ProviderType; import com.worlabel.domain.member.entity.Member; import com.worlabel.domain.member.repository.MemberRepository; @@ -10,13 +11,9 @@ 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.DefaultOAuth2User; import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.security.oauth2.core.user.OAuth2UserAuthority; import org.springframework.stereotype.Service; -import java.util.Collections; - /** * OAuth2 사용자 서비스 클래스 * OAuth2 인증을 통해 사용자 정보를 로드하고 처리하는 역할 @@ -24,7 +21,7 @@ import java.util.Collections; @Slf4j @Service @RequiredArgsConstructor -public class CustomOAuth2UserService extends DefaultOAuth2UserService { +public class CustomOAuth2MemberService extends DefaultOAuth2UserService { private final MemberRepository memberRepository; @@ -37,16 +34,14 @@ public class CustomOAuth2UserService extends DefaultOAuth2UserService { OAuth2Attribute attribute = OAuth2AttributeFactory.parseAttribute(provider, user.getAttributes()); log.debug("provider: {}, user: {}", provider, user); - Member findMember = memberRepository.findByProviderMemberId(attribute.getId()) + Member member = memberRepository.findByProviderMemberId(attribute.getId()) .orElseGet(() -> { - Member member = Member.of(attribute.getId(), attribute.getEmail(), attribute.getName(), attribute.getProfileImage()); - memberRepository.save(member); - return member; + Member newMember = Member.of(attribute.getId(), attribute.getEmail(), attribute.getName(), attribute.getProfileImage()); + memberRepository.save(newMember); + return newMember; }); - return new DefaultOAuth2User(Collections.singleton(new OAuth2UserAuthority(user.getAttributes())), - user.getAttributes(), - "sub" - ); + log.debug("member : {}", member); + return new CustomOAuth2Member(member); } } diff --git a/backend/src/main/java/com/worlabel/domain/auth/service/JwtTokenService.java b/backend/src/main/java/com/worlabel/domain/auth/service/JwtTokenService.java new file mode 100644 index 0000000..c4dfcfa --- /dev/null +++ b/backend/src/main/java/com/worlabel/domain/auth/service/JwtTokenService.java @@ -0,0 +1,9 @@ +package com.worlabel.domain.auth.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class JwtTokenService { +} diff --git a/backend/src/main/java/com/worlabel/domain/member/repository/MemberRepository.java b/backend/src/main/java/com/worlabel/domain/member/repository/MemberRepository.java index bbdb8b5..4b4442e 100644 --- a/backend/src/main/java/com/worlabel/domain/member/repository/MemberRepository.java +++ b/backend/src/main/java/com/worlabel/domain/member/repository/MemberRepository.java @@ -6,5 +6,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface MemberRepository extends JpaRepository { - public Optional findByProviderMemberId(String providerMemberId); + Optional findByProviderMemberId(String providerMemberId); + + Optional findByEmail(String email); } diff --git a/backend/src/main/java/com/worlabel/global/advice/CustomControllerAdvice.java b/backend/src/main/java/com/worlabel/global/advice/CustomControllerAdvice.java index 644320c..6534052 100644 --- a/backend/src/main/java/com/worlabel/global/advice/CustomControllerAdvice.java +++ b/backend/src/main/java/com/worlabel/global/advice/CustomControllerAdvice.java @@ -11,6 +11,8 @@ import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.NoHandlerFoundException; +import org.springframework.web.servlet.resource.NoResourceFoundException; @Slf4j @RestControllerAdvice @@ -29,11 +31,11 @@ public class CustomControllerAdvice { return ErrorResponse.of(new CustomException(ErrorCode.BAD_REQUEST)); } - @ExceptionHandler(CustomException.class) - public ResponseEntity handleCustomException(CustomException e) { + @ExceptionHandler(NoResourceFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public ErrorResponse handleNoHandlerFoundException(NoResourceFoundException e) { log.error("", e); - return ResponseEntity.status(e.getErrorCode().getStatus()) - .body(ErrorResponse.of(e)); + return ErrorResponse.of(new CustomException(ErrorCode.INVALID_URL)); } @ExceptionHandler({MissingServletRequestParameterException.class}) @@ -41,4 +43,11 @@ public class CustomControllerAdvice { log.error("",e); return ErrorResponse.of(new CustomException(ErrorCode.EMPTY_REQUEST_PARAMETER)); } + + @ExceptionHandler(CustomException.class) + public ResponseEntity handleCustomException(CustomException e) { + log.error("", e); + return ResponseEntity.status(e.getErrorCode().getStatus()) + .body(ErrorResponse.of(e)); + } } diff --git a/backend/src/main/java/com/worlabel/global/annotation/CurrentUser.java b/backend/src/main/java/com/worlabel/global/annotation/CurrentUser.java new file mode 100644 index 0000000..b4766e6 --- /dev/null +++ b/backend/src/main/java/com/worlabel/global/annotation/CurrentUser.java @@ -0,0 +1,12 @@ +package com.worlabel.global.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface CurrentUser { +} diff --git a/backend/src/main/java/com/worlabel/global/config/CorsMvcConfig.java b/backend/src/main/java/com/worlabel/global/config/CorsMvcConfig.java new file mode 100644 index 0000000..d138275 --- /dev/null +++ b/backend/src/main/java/com/worlabel/global/config/CorsMvcConfig.java @@ -0,0 +1,21 @@ +package com.worlabel.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsMvcConfig implements WebMvcConfigurer { + + @Value("${frontend.url}") + private String frontend; + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .exposedHeaders("Set-Cookie") + .allowedOrigins(frontend) // application.yml에서 가져온 값 사용 + .allowCredentials(true); + } +} diff --git a/backend/src/main/java/com/worlabel/global/config/SecurityConfig.java b/backend/src/main/java/com/worlabel/global/config/SecurityConfig.java index 209e26c..af97fe0 100644 --- a/backend/src/main/java/com/worlabel/global/config/SecurityConfig.java +++ b/backend/src/main/java/com/worlabel/global/config/SecurityConfig.java @@ -1,38 +1,98 @@ package com.worlabel.global.config; -import lombok.Builder; +import com.worlabel.domain.auth.service.CustomOAuth2MemberService; +import com.worlabel.domain.member.repository.MemberRepository; +import com.worlabel.global.filter.JWTFilter; +import com.worlabel.global.handler.OAuth2SuccessHandler; +import com.worlabel.global.util.JWTUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; 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.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +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.Collections; + +@Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final CustomOAuth2MemberService customOAuth2UserService; + private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final JWTUtil jwtUtil; + + @Value("${frontend.url}") + private String frontend; + @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http, MemberRepository memberRepository) throws Exception { + + // CSRF 비활성화 + http + .csrf((auth) -> auth.disable()); + + // Form 로그인 방식 Disable + http + .formLogin((auth) -> auth.disable()); + + // HTTP Basic 인증 방식 disable + http. + httpBasic((auth) -> auth.disable()); + + // OAuth2 http - // HTTP 요청에 대한 인증 및 권한 설정 - .authorizeHttpRequests(auth -> auth - // 경로는 모든 사용자가 접근 할 수 있도록 허용 - .requestMatchers("/**","/favicon.ico").permitAll() - // 그 외의 모든 요청은 인증해야함 - .anyRequest().authenticated() - ) - // OAuth2 로그인 설정 .oauth2Login(oauth2 -> oauth2 - .userInfoEndpoint(userInfo -> - // 사용자 정보를 처리하는 서비스로 DefaultOAuth2UserService - userInfo.userService(new DefaultOAuth2UserService())) - ) - // CSRF 보호 설정 - .csrf(csrf -> csrf // CSRF 보호 활성화 및 커스터마이징 - // CSRF토큰을 쿠키에 저장하도록 설정, HTTP-only 속성을 비활성화하여 자바스크립트에서 접근 가능하게 함 - .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .authorizationEndpoint(authorizationEndpoint -> + authorizationEndpoint.baseUri("/api/oauth2/authorization")) + .redirectionEndpoint(redirectionEndpoint -> + redirectionEndpoint.baseUri("/api/login/oauth2/code/*")) + .userInfoEndpoint(userInfoEndpointConfig -> + userInfoEndpointConfig.userService(customOAuth2UserService)) + .successHandler(oAuth2SuccessHandler) ); - // 설정을 바탕으로 SecurityFilterChain 빌드 + + + // 경로별 인가 작업 + http + .authorizeHttpRequests((auth)->auth + .requestMatchers("/favicon.ico").permitAll() // Allow access to favicon.ico + .requestMatchers("/").permitAll() + .anyRequest().authenticated()); + + // 세션 설정: STATELESS + http.sessionManagement((session)->session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + // JWT 필터 추가 + http + .addFilterAfter(new JWTFilter(jwtUtil, memberRepository), UsernamePasswordAuthenticationFilter.class); return http.build(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.setAllowedOrigins(Collections.singletonList(frontend)); // 프론트엔드 URL 사용 + configuration.setAllowedMethods(Collections.singletonList("*")); + configuration.setAllowCredentials(true); + configuration.setAllowedHeaders(Collections.singletonList("*")); + configuration.setMaxAge(3600L); + + configuration.addExposedHeader("Authorization"); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + } diff --git a/backend/src/main/java/com/worlabel/global/config/WebConfig.java b/backend/src/main/java/com/worlabel/global/config/WebConfig.java new file mode 100644 index 0000000..d727b71 --- /dev/null +++ b/backend/src/main/java/com/worlabel/global/config/WebConfig.java @@ -0,0 +1,21 @@ +package com.worlabel.global.config; + +import com.worlabel.global.resolver.CurrentUserArgumentResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final CurrentUserArgumentResolver currentUserArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(currentUserArgumentResolver); + } +} diff --git a/backend/src/main/java/com/worlabel/global/exception/ErrorCode.java b/backend/src/main/java/com/worlabel/global/exception/ErrorCode.java index f704db3..c18226d 100644 --- a/backend/src/main/java/com/worlabel/global/exception/ErrorCode.java +++ b/backend/src/main/java/com/worlabel/global/exception/ErrorCode.java @@ -13,7 +13,7 @@ public enum ErrorCode { EMPTY_FILE(HttpStatus.BAD_REQUEST, 1001, "빈 파일입니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, 1002, "잘못된 요청입니다. 요청을 확인해주세요."), EMPTY_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST, 1003, "필수 요청 파라미터가 입력되지 않았습니다."), - + INVALID_URL(HttpStatus.BAD_REQUEST, 1004, "제공하지 않는 주소입니다. 확인해주세요"), // Auth & User - 2000 USER_NOT_FOUND(HttpStatus.NOT_FOUND, 2000, "해당 ID의 사용자를 찾을 수 없습니다."), diff --git a/backend/src/main/java/com/worlabel/global/filter/JWTFilter.java b/backend/src/main/java/com/worlabel/global/filter/JWTFilter.java new file mode 100644 index 0000000..a618d44 --- /dev/null +++ b/backend/src/main/java/com/worlabel/global/filter/JWTFilter.java @@ -0,0 +1,53 @@ +package com.worlabel.global.filter; + +import com.worlabel.domain.auth.entity.CustomOAuth2Member; +import com.worlabel.domain.member.repository.MemberRepository; +import com.worlabel.global.util.JWTUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Optional; + +@Slf4j +@RequiredArgsConstructor +public class JWTFilter extends OncePerRequestFilter { + + private final JWTUtil jwtUtil; + private final MemberRepository memberRepository; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + Cookie[] cookies = request.getCookies(); + String token = Arrays.stream(Optional.ofNullable(cookies).orElse(new Cookie[0])) + .filter(cookie -> "Authorization".equals(cookie.getName())) + .map(Cookie::getValue) + .findFirst() + .orElse(null); + + if(token == null || jwtUtil.isExpired(token)){ + log.debug("토큰 X"); + filterChain.doFilter(request, response); + return; + } + + String username = jwtUtil.getUsername(token); + memberRepository.findByEmail(username).ifPresent(member -> { + CustomOAuth2Member customOAuth2Member = new CustomOAuth2Member(member); + Authentication authToken = new UsernamePasswordAuthenticationToken(customOAuth2Member.getAuthMemberDto(), null, customOAuth2Member.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authToken); + }); + + filterChain.doFilter(request, response); + } +} diff --git a/backend/src/main/java/com/worlabel/global/handler/OAuth2SuccessHandler.java b/backend/src/main/java/com/worlabel/global/handler/OAuth2SuccessHandler.java new file mode 100644 index 0000000..c916e83 --- /dev/null +++ b/backend/src/main/java/com/worlabel/global/handler/OAuth2SuccessHandler.java @@ -0,0 +1,64 @@ +package com.worlabel.global.handler; + +import com.worlabel.domain.auth.entity.CustomOAuth2Member; +import com.worlabel.global.util.JWTUtil; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + // TODO : 추후 도메인 혹은 배포시 설정 + @Value("${frontend.url}") + private String frontEnd; + + private final JWTUtil jwtUtil; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + + // OAuth2User + CustomOAuth2Member customOAuth2Member = (CustomOAuth2Member) authentication.getPrincipal(); + log.debug("로그인 성공 : {}", customOAuth2Member); + + String username = customOAuth2Member.getName(); + String role = authentication.getAuthorities() + .stream() + .map(auth -> auth.getAuthority()) + .toList() + .get(0); + + // JWT 토큰 생성 + String token = jwtUtil.createJwtToken(username, role, 60 * 60 * 1000L, "access"); + // 쿠키에 JWT 토큰 추가 + response.addCookie(createCookie("Authorization", token)); + + // 성공 시 리다이렉트할 URL 설정 +// String redirectUrl = frontEnd + "/"; // 적절한 리다이렉트 URL로 수정 +// getRedirectStrategy().sendRedirect(request, response, redirectUrl); + super.onAuthenticationSuccess(request, response, authentication); + } + + private Cookie createCookie(String key, String value) { + Cookie cookie = new Cookie(key, value); + // TODO: 추후 변경 + cookie.setMaxAge(60 * 60 * 60); // 개발 단계에서는 유효기간 길게 + cookie.setPath("/"); // 쿠키 경로를 전체 경로로 설정 + cookie.setHttpOnly(true); // HttpOnly 설정, JavaScript 접근 불가 +// cookie.setSecure(true); // TODO: 배포시 HTTPS 환경에서 사용 + return cookie; + } +} diff --git a/backend/src/main/java/com/worlabel/global/resolver/CurrentUserArgumentResolver.java b/backend/src/main/java/com/worlabel/global/resolver/CurrentUserArgumentResolver.java new file mode 100644 index 0000000..cbe8676 --- /dev/null +++ b/backend/src/main/java/com/worlabel/global/resolver/CurrentUserArgumentResolver.java @@ -0,0 +1,32 @@ +package com.worlabel.global.resolver; + +import com.worlabel.domain.auth.dto.AuthMemberDto; +import com.worlabel.domain.auth.entity.CustomOAuth2Member; +import com.worlabel.global.annotation.CurrentUser; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Slf4j +@Component +public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUser.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + if(principal instanceof AuthMemberDto){ + return principal; + } + + return null; + } +} diff --git a/backend/src/main/java/com/worlabel/global/util/JWTUtil.java b/backend/src/main/java/com/worlabel/global/util/JWTUtil.java new file mode 100644 index 0000000..667963c --- /dev/null +++ b/backend/src/main/java/com/worlabel/global/util/JWTUtil.java @@ -0,0 +1,51 @@ +package com.worlabel.global.util; + +import io.jsonwebtoken.Jwts; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Component +public class JWTUtil { + + private final SecretKey secretKey; + + public JWTUtil(@Value("${spring.jwt.secret}") String key) { + secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm()); + } + + public String getUsername(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class); + } + + public String getRole(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class); + } + + + public boolean isExpired(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date()); + } + + /** + * AccessToken 생성 + * @param username 사용자 아이디(이메일) + * @param role 사용자 권한 + * @param expiredMs 토큰 만료시간 + * @return AccessToken + */ + public String createJwtToken(String username, String role, Long expiredMs, String type) { + return Jwts.builder() + .claim("username", username) + .claim("role", role) + .claim("type", type) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } + +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..af10094 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,53 @@ +spring: + application: + name: worlabel + + # MYSQL 데이터베이스 설정 + datasource: + url: jdbc:mysql://localhost:3306/worlabel?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: root + password: 1234 + driver-class-name: com.mysql.cj.jdbc.Driver + + # 하이버네이트 설정 + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + + # MultiPartFile MAXSIZE 설정(추후 구현) + servlet: + multipart: + # max-file-size: 10MB + # max-request-size: 10MB + + # OAuth 설정 + security: + oauth2: + client: + registration: + google: + client-name: google + client-id: 1044731352382-tphfhlmjit95c6k8ac5753e6g197acg2.apps.googleusercontent.com + client-secret: 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 + redirect-uri: http://localhost:8080/api/login/oauth2/code/google + authorization-grant-type: authorization_code + scope: + - profile + - email + # JWT 설정 + jwt: + secret: 9fsVIzHXKXAYkls8RaGmwex4PB7tNxqj + +# 로깅 레벨 설정 +logging: + level: + com.worlabel: DEBUG + + +# Frontend 도메인(추후 변경 예정) +frontend: + url: http://localhost:8080 \ No newline at end of file