diff --git a/build.gradle b/build.gradle index c3584ad6..027fa57c 100644 --- a/build.gradle +++ b/build.gradle @@ -44,6 +44,9 @@ dependencies { //Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + //OAuth 2.0 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + //swagger implementation 'org.springdoc:springdoc-openapi-ui:1.7.0' diff --git a/src/main/java/com/uspray/uspray/config/SecurityConfig.java b/src/main/java/com/uspray/uspray/config/SecurityConfig.java index 2086c3d8..49227fd8 100644 --- a/src/main/java/com/uspray/uspray/config/SecurityConfig.java +++ b/src/main/java/com/uspray/uspray/config/SecurityConfig.java @@ -1,5 +1,8 @@ package com.uspray.uspray.config; +import com.uspray.uspray.external.client.oauth2.CustomOAuth2UserService; +import com.uspray.uspray.external.client.oauth2.OAuth2LoginFailureHandler; +import com.uspray.uspray.external.client.oauth2.OAuth2LoginSuccessHandler; import com.uspray.uspray.jwt.JwtAccessDeniedHandler; import com.uspray.uspray.jwt.JwtAuthenticationEntryPoint; import com.uspray.uspray.jwt.TokenProvider; @@ -7,6 +10,7 @@ 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.configuration.WebSecurityCustomizer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -15,11 +19,15 @@ @Configuration @RequiredArgsConstructor +@EnableWebSecurity public class SecurityConfig { private final TokenProvider tokenProvider; private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; + private final OAuth2LoginFailureHandler oAuth2LoginFailureHandler; @Bean public PasswordEncoder passwordEncoder() { @@ -35,25 +43,36 @@ public WebSecurityCustomizer webSecurityCustomizer() { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() - .exceptionHandling() - .authenticationEntryPoint(jwtAuthenticationEntryPoint) - .accessDeniedHandler(jwtAccessDeniedHandler) - .and() - .headers() - .frameOptions() - .sameOrigin() - .and() - .sessionManagement() - .sessionCreationPolicy(SessionCreationPolicy.STATELESS) - .and() - .authorizeRequests() - .antMatchers("/auth/**").permitAll() - .antMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**", "/swagger-ui.html").permitAll() - .antMatchers("/sms/**").permitAll() - .antMatchers("/admin/**").permitAll() - .anyRequest().authenticated() - .and() - .apply(new JwtSecurityConfig(tokenProvider)); + .exceptionHandling() + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler) + + .and() + .headers() + .frameOptions() + .sameOrigin() + + .and() + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + + .and() + .authorizeRequests() + .antMatchers("/","/css/**","/images/**","/js/**","/favicon.ico","/h2-console/**").permitAll() + .antMatchers("/auth/**").permitAll() + .antMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**", "/swagger-ui.html").permitAll() + .antMatchers("/sms/**").permitAll() + .antMatchers("/admin/**").permitAll() + .anyRequest().authenticated() + .and() + .apply(new JwtSecurityConfig(tokenProvider)); + + http.oauth2Login() + .successHandler(oAuth2LoginSuccessHandler) // 동의하고 계속하기를 눌렀을 때 Handler 설정 + .failureHandler(oAuth2LoginFailureHandler) // 소셜 로그인 실패 시 핸들러 설정 + .userInfoEndpoint().userService(customOAuth2UserService) // customUserService 설정 + .and() + .permitAll(); return http.build(); } diff --git a/src/main/java/com/uspray/uspray/controller/AuthController.java b/src/main/java/com/uspray/uspray/controller/AuthController.java index 7fc0eaf9..a4394f19 100644 --- a/src/main/java/com/uspray/uspray/controller/AuthController.java +++ b/src/main/java/com/uspray/uspray/controller/AuthController.java @@ -9,7 +9,6 @@ import com.uspray.uspray.DTO.auth.request.MemberRequestDto; import com.uspray.uspray.DTO.auth.response.MemberResponseDto; import com.uspray.uspray.exception.SuccessStatus; -import com.uspray.uspray.jwt.TokenProvider; import com.uspray.uspray.service.AuthService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -36,9 +35,7 @@ @RequiredArgsConstructor @Tag(name = "Auth", description = "Auth 관련 API") public class AuthController { - - private final TokenProvider tokenProvider; - private final AuthService authService; + private final AuthService authService; @PostMapping("/signup") @ApiResponse( diff --git a/src/main/java/com/uspray/uspray/domain/Member.java b/src/main/java/com/uspray/uspray/domain/Member.java index b8364ac2..a345ce0f 100644 --- a/src/main/java/com/uspray/uspray/domain/Member.java +++ b/src/main/java/com/uspray/uspray/domain/Member.java @@ -23,37 +23,50 @@ @Where(clause = "deleted=false") public class Member extends AuditingTimeEntity { + private final Boolean deleted = false; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "member_id") private Long id; - private String userId; private String password; - private String name; private String phone; private String birth; private String gender; private String firebaseToken; - private Boolean firstNotiAgree = true; private Boolean secondNotiAgree = true; private Boolean thirdNotiAgree = true; - - private final Boolean deleted = false; - + private String socialId; @Enumerated(EnumType.STRING) private Authority authority; + @OneToMany(mappedBy = "author") + private List groupPrayList; + @ManyToMany @JoinTable(name = "member_group", joinColumns = @JoinColumn(name = "member_id"), inverseJoinColumns = @JoinColumn(name = "group_id")) private Set groups = new HashSet<>(); - @OneToMany(mappedBy = "author") - private List groupPrayList; + + @Builder + public Member(String userId, String password, String name, String phone, String birth, + String gender, Authority authority, String socialId) { + this.userId = userId; + this.password = password; + this.name = name; + this.phone = phone; + this.birth = birth; + this.gender = gender; + this.socialId = socialId; + this.authority = authority; + } + public void changeSocialId(String socialId) { + this.socialId = socialId; + } public void changeFirebaseToken(String firebaseToken) { this.firebaseToken = firebaseToken; @@ -75,18 +88,6 @@ public void leaveGroup(Group group) { this.groups.remove(group); } - @Builder - public Member(String userId, String password, String name, String phone, String birth, - String gender, Authority authority) { - this.userId = userId; - this.password = password; - this.name = name; - this.phone = phone; - this.birth = birth; - this.gender = gender; - this.authority = authority; - } - public void changeNotificationSetting(NotificationAgreeDto notificationAgreeDto) { switch (notificationAgreeDto.getNotificationType()) { case PRAY_TIME: diff --git a/src/main/java/com/uspray/uspray/exception/SuccessStatus.java b/src/main/java/com/uspray/uspray/exception/SuccessStatus.java index 162380bf..41bcab26 100644 --- a/src/main/java/com/uspray/uspray/exception/SuccessStatus.java +++ b/src/main/java/com/uspray/uspray/exception/SuccessStatus.java @@ -8,7 +8,6 @@ @Getter @RequiredArgsConstructor(access = AccessLevel.PRIVATE) public enum SuccessStatus { - /** * 200 OK */ @@ -40,6 +39,7 @@ public enum SuccessStatus { CHANGE_GROUP_LEADER_SUCCESS(HttpStatus.OK, "모임 리더 위임에 성공했습니다."), KICK_GROUP_MEMBER_SUCCESS(HttpStatus.OK, "모임 멤버 내보내기에 성공했습니다."), ADD_GROUP_MEMBER_SUCCESS(HttpStatus.OK, "모임 멤버 추가하기에 성공했습니다."), + TEST_SUCCESS(HttpStatus.OK, "Test :: OK"), /* * 201 created diff --git a/src/main/java/com/uspray/uspray/external/client/oauth2/CustomOAuth2User.java b/src/main/java/com/uspray/uspray/external/client/oauth2/CustomOAuth2User.java new file mode 100644 index 00000000..3d6237a4 --- /dev/null +++ b/src/main/java/com/uspray/uspray/external/client/oauth2/CustomOAuth2User.java @@ -0,0 +1,21 @@ +package com.uspray.uspray.external.client.oauth2; + +import com.uspray.uspray.Enums.Authority; +import java.util.Collection; +import java.util.Map; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; + +@Getter +public class CustomOAuth2User extends DefaultOAuth2User { + private final Authority authority; + + public CustomOAuth2User( + Collection authorities, + Map attributes, String nameAttributeKey, + Authority authority) { + super(authorities, attributes, nameAttributeKey); + this.authority = authority; + } +} diff --git a/src/main/java/com/uspray/uspray/external/client/oauth2/CustomOAuth2UserService.java b/src/main/java/com/uspray/uspray/external/client/oauth2/CustomOAuth2UserService.java new file mode 100644 index 00000000..ce9c3fa6 --- /dev/null +++ b/src/main/java/com/uspray/uspray/external/client/oauth2/CustomOAuth2UserService.java @@ -0,0 +1,100 @@ +package com.uspray.uspray.external.client.oauth2; + +import com.uspray.uspray.domain.Member; +import com.uspray.uspray.infrastructure.MemberRepository; +import java.util.Collections; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.RandomStringUtils; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService implements OAuth2UserService { + + private final MemberRepository memberRepository; + + /** + * DefaultOAuth2UserService 객체를 생성하여, loadUser(userRequest)를 통해 DefaultOAuth2User 객체를 생성 후 반환 + * DefaultOAuth2UserService의 loadUser()는 소셜 로그인 API의 사용자 정보 제공 URI로 요청을 보내서 + * 사용자 정보를 얻은 후, 이를 통해 DefaultOAuth2User 객체를 생성 후 반환한다. + * 결과적으로, OAuth2User는 OAuth 서비스에서 가져온 유저 정보를 담고 있는 유저 + */ + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2UserService delegate = new DefaultOAuth2UserService(); + OAuth2User oAuth2User = delegate.loadUser(userRequest); // OAuth2 정보를 가져옵니다. + + //현재 로그인 진행 중인 서비스를 구분하는 코드 (구글 or 네이버 or 카카오 ...) + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + //OAuth2 로그인 진행 시 키가되는 필드 값, Primary Key와 같은 의미 + String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails() + .getUserInfoEndpoint().getUserNameAttributeName(); + + //OAuth2UserService를 통해 가져온 OAuth2User의 attribute를 담을 클래스 + // 소셜 로그인에서 API가 제공하는 userInfo의 Json 값(유저 정보들) + OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, + oAuth2User.getAttributes()); + + Member member = getMember(attributes); + + return new CustomOAuth2User( + Collections.singleton(new SimpleGrantedAuthority(member.getAuthority().name())), + oAuth2User.getAttributes(), + attributes.getNameAttributeKey(), + member.getAuthority() + ); + } + + private Member getMember(OAuthAttributes attributes) { + Member findMember = memberRepository.findBySocialId( + attributes.getOAuth2UserInfo().getId()).orElse(null); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication != null) { //소셜 로그인 연동 + return saveMember(attributes, authentication.getName()); + } + + if (findMember == null) { + return saveMember(attributes); + } + + return findMember; + } + + /** + * 이미 존재하는 회원이라면 이름과 프로필이미지를 업데이트해줍니다. + * 처음 가입하는 회원이라면 Member 테이블을 생성합니다. (소셜 회원가입) + **/ + private Member saveMember(OAuthAttributes attributes, String userId) { + //기존 유저와 아이디가 같으면 같은 유저임 + // update는 기존 유저의 소셜 ID 컬럼에 값을 추가하는 것 정도만 있으면 될듯 + Member member = memberRepository.getMemberByUserId(userId); + member.changeSocialId(attributes.getOAuth2UserInfo().getId()); + return memberRepository.save(member); + } + + private Member saveMember(OAuthAttributes attributes) { + //기존 유저와 아이디가 같으면 같은 유저임 + // update는 기존 유저의 소셜 ID 컬럼에 값을 추가하는 것 정도만 있으면 될듯 + Member member = attributes.toEntity(attributes.getOAuth2UserInfo(), generateRandomId()); + return memberRepository.save(member); + } + + private String generateRandomId() { + while (true) { + String randomId = RandomStringUtils.random(15, true, true); + if (!memberRepository.existsByUserId(randomId)) { + return randomId; + } + } + } + +} diff --git a/src/main/java/com/uspray/uspray/external/client/oauth2/OAuth2LoginFailureHandler.java b/src/main/java/com/uspray/uspray/external/client/oauth2/OAuth2LoginFailureHandler.java new file mode 100644 index 00000000..98822072 --- /dev/null +++ b/src/main/java/com/uspray/uspray/external/client/oauth2/OAuth2LoginFailureHandler.java @@ -0,0 +1,23 @@ +package com.uspray.uspray.external.client.oauth2; + +import java.io.IOException; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class OAuth2LoginFailureHandler implements AuthenticationFailureHandler { + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.getWriter().write("소셜 로그인이 실패하였습니다."); + log.error("소셜 로그인에 실패했습니다. 에러메세지 : {}", exception.getMessage()); + } +} diff --git a/src/main/java/com/uspray/uspray/external/client/oauth2/OAuth2LoginSuccessHandler.java b/src/main/java/com/uspray/uspray/external/client/oauth2/OAuth2LoginSuccessHandler.java new file mode 100644 index 00000000..aef6bf58 --- /dev/null +++ b/src/main/java/com/uspray/uspray/external/client/oauth2/OAuth2LoginSuccessHandler.java @@ -0,0 +1,48 @@ +package com.uspray.uspray.external.client.oauth2; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.uspray.uspray.DTO.ApiResponseDto; +import com.uspray.uspray.DTO.auth.TokenDto; +import com.uspray.uspray.exception.SuccessStatus; +import com.uspray.uspray.jwt.TokenProvider; +import java.io.IOException; +import java.util.concurrent.TimeUnit; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { + + private final TokenProvider tokenProvider; + private final RedisTemplate redisTemplate; + private final ObjectMapper mapper = new ObjectMapper(); + + @Override + @Transactional + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + + TokenDto tokenDto = tokenProvider.generateTokenDto(authentication); + redisTemplate.opsForValue().set("RT:" + authentication.getName(), + tokenDto.getRefreshToken(), + tokenProvider.getRefreshTokenExpireTime(), + TimeUnit.MILLISECONDS); + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json"); + response.setCharacterEncoding("utf-8"); + response.getWriter().write(mapper.writeValueAsString( + ApiResponseDto.success(SuccessStatus.LOGIN_SUCCESS, tokenDto) + )); + // 로그인에 성공한 경우 access, refresh 토큰 생성 + } +} diff --git a/src/main/java/com/uspray/uspray/external/client/oauth2/OAuthAttributes.java b/src/main/java/com/uspray/uspray/external/client/oauth2/OAuthAttributes.java new file mode 100644 index 00000000..16632541 --- /dev/null +++ b/src/main/java/com/uspray/uspray/external/client/oauth2/OAuthAttributes.java @@ -0,0 +1,56 @@ +package com.uspray.uspray.external.client.oauth2; + +import com.uspray.uspray.Enums.Authority; +import com.uspray.uspray.domain.Member; +import com.uspray.uspray.external.client.oauth2.dto.KakaoOAuth2UserInfo; +import com.uspray.uspray.external.client.oauth2.dto.NaverOAuth2UserInfo; +import com.uspray.uspray.external.client.oauth2.dto.OAuth2UserInfo; +import java.util.Map; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class OAuthAttributes { + private final String nameAttributeKey; + private final OAuth2UserInfo oAuth2UserInfo; + + @Builder + public OAuthAttributes(String nameAttributeKey, OAuth2UserInfo oAuth2UserInfo) { + this.nameAttributeKey = nameAttributeKey; + this.oAuth2UserInfo = oAuth2UserInfo; + } + + public static OAuthAttributes of(String registrationId, String userNameAttributeName, + Map attributes) { + if ("naver".equals(registrationId)) { + return ofNaver(userNameAttributeName, attributes); + } + return ofKakao(userNameAttributeName, attributes); + } + + private static OAuthAttributes ofNaver(String userNameAttributeName, + Map attributes) { + return OAuthAttributes.builder() + .nameAttributeKey(userNameAttributeName) + .oAuth2UserInfo(new NaverOAuth2UserInfo(attributes)) + .build(); + } + + private static OAuthAttributes ofKakao(String userNameAttributeName, + Map attributes) { + return OAuthAttributes.builder() + .nameAttributeKey(userNameAttributeName) + .oAuth2UserInfo(new KakaoOAuth2UserInfo(attributes)) + .build(); + } + + public Member toEntity(OAuth2UserInfo oAuth2UserInfo, String randomId) { + return Member.builder() + .name("Uspray") + .userId(randomId) + .socialId(oAuth2UserInfo.getId()) + .authority(Authority.ROLE_USER) + .build(); + } + +} diff --git a/src/main/java/com/uspray/uspray/external/client/oauth2/dto/KakaoOAuth2UserInfo.java b/src/main/java/com/uspray/uspray/external/client/oauth2/dto/KakaoOAuth2UserInfo.java new file mode 100644 index 00000000..19c13686 --- /dev/null +++ b/src/main/java/com/uspray/uspray/external/client/oauth2/dto/KakaoOAuth2UserInfo.java @@ -0,0 +1,27 @@ +package com.uspray.uspray.external.client.oauth2.dto; + +import java.util.Map; + +public class KakaoOAuth2UserInfo extends OAuth2UserInfo{ + + public KakaoOAuth2UserInfo(Map attributes) { + super(attributes); + } + + @Override + public String getId() { + return String.valueOf(attributes.get("id")); + } + +// @Override +// public String getNickname() { +// Map account = (Map) attributes.get("kakao_account"); +// Map profile = (Map) account.get("profile"); +// +// if (profile == null) { +// return null; +// } +// +// return (String) profile.get("nickname"); +// } +} diff --git a/src/main/java/com/uspray/uspray/external/client/oauth2/dto/NaverOAuth2UserInfo.java b/src/main/java/com/uspray/uspray/external/client/oauth2/dto/NaverOAuth2UserInfo.java new file mode 100644 index 00000000..5b4df084 --- /dev/null +++ b/src/main/java/com/uspray/uspray/external/client/oauth2/dto/NaverOAuth2UserInfo.java @@ -0,0 +1,31 @@ +package com.uspray.uspray.external.client.oauth2.dto; + +import java.util.Map; + +public class NaverOAuth2UserInfo extends OAuth2UserInfo{ + + public NaverOAuth2UserInfo(Map attributes) { + super(attributes); + } + + @Override + public String getId() { + Map response = (Map) attributes.get("response"); + + if (response == null) { + return null; + } + return (String) response.get("id"); + } + +// @Override +// public String getNickname() { +// Map response = (Map) attributes.get("response"); +// +// if (response == null) { +// return null; +// } +// +// return (String) response.get("nickname"); +// } +} diff --git a/src/main/java/com/uspray/uspray/external/client/oauth2/dto/OAuth2UserInfo.java b/src/main/java/com/uspray/uspray/external/client/oauth2/dto/OAuth2UserInfo.java new file mode 100644 index 00000000..ffd65344 --- /dev/null +++ b/src/main/java/com/uspray/uspray/external/client/oauth2/dto/OAuth2UserInfo.java @@ -0,0 +1,17 @@ +package com.uspray.uspray.external.client.oauth2.dto; + +import java.util.Map; + +public abstract class OAuth2UserInfo { + + protected Map attributes; + + public OAuth2UserInfo(Map attributes) { + this.attributes = attributes; + } + + public abstract String getId(); //소셜 식별 값 : 구글 - "sub", 카카오 - "id", 네이버 - "id" + +// public abstract String getNickname(); + +} diff --git a/src/main/java/com/uspray/uspray/infrastructure/MemberRepository.java b/src/main/java/com/uspray/uspray/infrastructure/MemberRepository.java index 5f9ccd8c..7c2de258 100644 --- a/src/main/java/com/uspray/uspray/infrastructure/MemberRepository.java +++ b/src/main/java/com/uspray/uspray/infrastructure/MemberRepository.java @@ -12,6 +12,7 @@ public interface MemberRepository extends JpaRepository { Optional findByUserId(String userId); + Optional findBySocialId(String socialId); boolean existsByUserId(String userId);