✅ 이전 포스팅
2024.11.19 - [Java/쇼핑몰] - 쇼핑몰 - 5 [spring security + JWT + Redis 로그인]
이전에는 JWT와 Redis를 이용해서 일반 로그인을 구현하는 방법에 대해 알아보았다.
이번 포스팅에서는 일반 로그인이 아닌 소셜 로그인을 구현하는 방법에 대해서 알아본다.
✅ 참고사항 및 안내
https://www.youtube.com/watch?v=7fMVxohRvl0&list=PLJkjrxxiBSFBPceOMrCQmuI8qipT7JD6w
위 유튜브의 내용처럼 Oauth 로직을 전부 백엔드에서 담당하여 처리하였기 때문에
Spring Security를 이용해서 처리하였다.
✅ Spring Security
package toy.shop.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import toy.shop.jwt.JwtAccessDeniedHandler;
import toy.shop.jwt.JwtAuthenticationFilter;
import toy.shop.jwt.JwtExceptionHandler;
import toy.shop.jwt.JwtProvider;
import toy.shop.service.oauth.CustomFailHandler;
import toy.shop.service.oauth.CustomOAuth2UserService;
import toy.shop.service.oauth.CustomSuccessHandler;
import java.util.Arrays;
import java.util.List;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final String[] allowedUrls = {
"/swagger-ui/**",
"/v3/**",
"/api/global/**",
"/api/auth/**",
"/images/**"
};
private final JwtProvider jwtProvider;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final JwtExceptionHandler jwtExceptionHandler;
private final CustomOAuth2UserService customOAuth2UserService;
private final CustomSuccessHandler customSuccessHandler;
private final CustomFailHandler customFailHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(AbstractHttpConfigurer::disable) // CSRF 보호 비활성화
.formLogin(AbstractHttpConfigurer::disable) // Form 로그인 비활성화
.httpBasic(AbstractHttpConfigurer::disable) // http basic 인증 방식 비활성화
.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) // X-Frame-Options 설정
// 경로 작업
.authorizeHttpRequests(request -> request
.requestMatchers(allowedUrls).permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").hasAnyRole("USER", "COMPANY", "ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthenticationFilter(jwtProvider, jwtExceptionHandler), UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(handling -> handling
.accessDeniedHandler(jwtAccessDeniedHandler))
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig
.userService(customOAuth2UserService))
.successHandler(customSuccessHandler)
.failureHandler(customFailHandler)
)
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); // Session 사용하지 않음
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000")); // 모든 도메인 허용, 필요시 특정 도메인 설정 가능
configuration.setAllowedMethods(List.of("*")); // 허용할 HTTP 메서드
configuration.setAllowedHeaders(List.of("*")); // 모든 헤더 허용
configuration.setAllowCredentials(true); // 자격 증명 허용
configuration.setExposedHeaders(Arrays.asList("Set-Cookie", "Authorization"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration); // 모든 경로에 대해 CORS 설정 적용
return source;
}
}
Spring Security에 oauth2Login을 추가적으로 작성한다.
코드를 보면 이해하기 어렵지 않아서 어떤 방식으로 작동하는지 쉽게 알 수 있을 것 같다.
Security를 이용하면 클라이언트 측에서 http://localhost:8080/oauth2/authorization/google 과 같이
하이퍼링크 형식으로 클릭하면 자연스럽게 소셜 로그인 팝업이 나타나고 로그인 이후 Security에 지정한 로직이
동작하도록 되어있다.
해당 방식에는 한 가지 단점이 존재하는데 그것은 아래 성공 후 로직을 작성하는 부분에서 다루도록 한다.
✅ CustomOAuth2UserService
package toy.shop.service.oauth;
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.OAuth2Error;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import toy.shop.domain.Role;
import toy.shop.domain.member.Member;
import toy.shop.dto.oauth.*;
import toy.shop.repository.member.MemberRepository;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final MemberRepository memberRepository;
@Override
@Transactional
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
OAuth2Response oAuth2Response = null;
if (registrationId.equals("google")) {
oAuth2Response = new GoogleResponse(oAuth2User.getAttributes());
} else if (registrationId.equals("naver")) {
oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
} else if (registrationId.equals("kakao")) {
oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
} else {
throw new OAuth2AuthenticationException(new OAuth2Error("unsupported_social_login"), "허용되지 않은 소셜 로그인입니다: " + registrationId);
}
String socialName = oAuth2Response.getProvider() + "_" + oAuth2Response.getProviderId();
Optional<Member> memberOptional = memberRepository.findByEmail(oAuth2Response.getEmail());
if (memberOptional.isPresent()) {
Member member = memberOptional.get();
if (member.getSocialName() == null) {
throw new OAuth2AuthenticationException(new OAuth2Error("conflict"), "해당 메일로 가입된 일반 계정이 존재합니다: " + oAuth2Response.getEmail());
}
if (!member.getSocialName().equals(socialName)) {
throw new OAuth2AuthenticationException(new OAuth2Error("conflict"), "이미 다른 소셜 계정으로 가입된 이메일입니다: " + oAuth2Response.getEmail());
}
member.setImagePath(oAuth2Response.getProfileUrl());
member.setNickName(oAuth2Response.getName());
} else {
Member member = new Member(
oAuth2Response.getEmail(),
oAuth2Response.getName(),
Role.ROLE_USER,
oAuth2Response.getProfileUrl(),
socialName
);
memberRepository.save(member);
}
UserDTO userDTO = UserDTO.builder()
.socialName(socialName)
.name(oAuth2Response.getName())
.email(oAuth2Response.getEmail())
.profileImage(oAuth2Response.getProfileUrl())
.role(Role.ROLE_USER)
.build();
return new CustomOAuthUser(userDTO);
}
}
CustomOAuth2UserService에서는 클라이언트에서 소셜 로그인을 진행한 뒤 검증하는 로직이다.
어떤 소셜 로그인을 하였는지 / 현재 DB에 해당 이메일로 가입된 회원은 없는지 등을 검증하고
이상이 없다면 회원가입을 진행한 뒤 User 정보를 반환한다.
✅ Oauth DTO
위 CustomOAuth2UserService에서 DTO가 많이 나오는데 해당 코드들이다.
🟨 OAuth2Response
package toy.shop.dto.oauth;
public interface OAuth2Response {
// 제공자
String getProvider();
// 제공자에서 발급해주는 아이디(번호)
String getProviderId();
// 이메일
String getEmail();
// 사용자 이름
String getName();
// 프로필 사진 url
String getProfileUrl();
}
소셜마다 받는 정보가 다르기 때문에 인터페이스화 시켜주었다.
🟨 GoogleResponse
package toy.shop.dto.oauth;
import java.util.Map;
public class GoogleResponse implements OAuth2Response{
private final Map<String, Object> attribute;
public GoogleResponse(Map<String, Object> attribute) {
this.attribute = attribute;
}
@Override
public String getProvider() {
return "google";
}
@Override
public String getProviderId() {
return attribute.get("sub").toString();
}
@Override
public String getEmail() {
return attribute.get("email").toString();
}
@Override
public String getName() {
return attribute.get("name").toString();
}
@Override
public String getProfileUrl() {
return attribute.get("picture").toString();
}
}
🟨 KakaoResponse
package toy.shop.dto.oauth;
import java.util.Map;
public class KakaoResponse implements OAuth2Response {
private Map<String, Object> attribute;
private Map<String, Object> kakaoAccountAttributes;
private Map<String, Object> profileAttributes;
public KakaoResponse(Map<String, Object> attribute) {
this.attribute = attribute;
this.kakaoAccountAttributes = (Map<String, Object>) attribute.get("kakao_account");
this.profileAttributes = (Map<String, Object>) kakaoAccountAttributes.get("profile");
}
@Override
public String getProvider() {
return "kakao";
}
@Override
public String getProviderId() {
return attribute.get("id").toString();
}
@Override
public String getEmail() {
return kakaoAccountAttributes.get("email").toString();
}
@Override
public String getName() {
return profileAttributes.get("nickname").toString();
}
@Override
public String getProfileUrl() {
return profileAttributes.get("profile_image_url").toString();
}
}
🟨 NaverResponse
package toy.shop.dto.oauth;
import lombok.ToString;
import java.util.Map;
@ToString
public class NaverResponse implements OAuth2Response {
private final Map<String, Object> attribute;
public NaverResponse(Map<String, Object> attribute) {
this.attribute = (Map<String, Object>) attribute.get("response");
}
@Override
public String getProvider() {
return "naver";
}
@Override
public String getProviderId() {
return attribute.get("id").toString();
}
@Override
public String getEmail() {
return attribute.get("email").toString();
}
@Override
public String getName() {
return attribute.get("name").toString();
}
@Override
public String getProfileUrl() {
return attribute.get("profile_image").toString();
}
}
🟨 CustomOAuthUser
package toy.shop.dto.oauth;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.user.OAuth2User;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Map;
public class CustomOAuthUser implements OAuth2User {
private final UserDTO userDTO;
public CustomOAuthUser(UserDTO userDTO) {
this.userDTO = userDTO;
}
@Override
public Map<String, Object> getAttributes() {
return null;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return userDTO.getRole().getRole();
}
});
return collection;
}
@Override
public String getName() {
return userDTO.getName();
}
public String getSocialName() {
return userDTO.getSocialName();
}
public String getEmail() {
return userDTO.getEmail();
}
public String getProfileImage() {
return userDTO.getProfileImage();
}
}
🟨 UserDTO
package toy.shop.dto.oauth;
import lombok.Builder;
import lombok.Data;
import toy.shop.domain.Role;
@Data
@Builder
public class UserDTO {
private String name;
private String socialName;
private String email;
private String profileImage;
private Role role;
}
✅ CustomSuccessHandler
package toy.shop.service.oauth;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import toy.shop.cmmn.exception.ConflictException;
import toy.shop.dto.jwt.JwtResponseDTO;
import toy.shop.dto.oauth.CustomOAuthUser;
import java.io.IOException;
import java.net.URLEncoder;
import java.util.Collection;
import java.util.Iterator;
@Slf4j
@Component
@RequiredArgsConstructor
public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final MemberTokenHelper memberTokenHelper;
private final String SERVER = "Server";
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
// 클라이언트 IP 가져오기
String clientIp = "http://localhost:3000";
try {
CustomOAuthUser customUserDetails = (CustomOAuthUser) authentication.getPrincipal();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth = iterator.next();
String role = auth.getAuthority();
// 토근 발급 및 Redis에 RefreshToken 저장
JwtResponseDTO token = memberTokenHelper.generateAndStoreToken(SERVER, customUserDetails.getEmail(), role);
response.sendRedirect(clientIp + "?accessToken=" + token.getAccessToken() + "&refreshToken=" + token.getRefreshToken());
} catch (ConflictException ex) {
// 예외 발생 시 클라이언트에 에러 메시지를 전달하기 위해 리디렉트
response.sendRedirect(clientIp + "/error?message=" + URLEncoder.encode(ex.getMessage(), "UTF-8"));
}
}
}
소셜 로그인이 성공하면 진행되는 코드이다.
여기서 백엔드가 OAuth 로그인을 전부 도맡으면 발생하는 문제점이 하나 생긴다.
바로 JSON 형식의 데이터를 전달할 수 없다는 점이다.
클라이언트에서 하이퍼링크 형식으로 URL을 클릭하여 진행하였기 때문에 서버에서 프론트로 리다이렉팅
해주지 않으면 서버 도메인으로 리다이렉트 돼버린다.
그렇기 때문에 서버는 클라이언트로 리다이렉팅 시켜주면서 토큰 값을 전달해 주어야 하는데 여기서 난관에 봉착했다.
1. Cookie에 토큰 값을 담아 전달.
보안상 이유로 인해 처음에는 Cookie에 토큰 값을 담아서 전달하였으나 배포 이후 통신에서 문제가 발생했다.
바로 서로 다른 도메인에서는 Cookie가 생성되지 않는다는 문제....
이를 해결하기 위해서는 도메인에 인증서를 발급하여 Https 통신을 해야 하는데 가벼운 사이드 프로젝트에 인증서를
넣기는 부담스러워서 과감하게 포기하였다.
2. 리다이렉트 URL에 토근 값을 담아 전달
굉장히 부적절한 데이터 전달 방식이지만 위 방법 밖에 남은 방식이 없었고 사이드 프로젝트임을 감안하여
해당 방식으로 토큰 값을 전달하기로 결정했다.
🟨 MemberTokenHelper
package toy.shop.service.oauth;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import toy.shop.dto.jwt.JwtResponseDTO;
import toy.shop.jwt.JwtProvider;
import toy.shop.service.RedisService;
@Component
@RequiredArgsConstructor
public class MemberTokenHelper {
private final JwtProvider jwtProvider;
private final RedisService redisService;
public JwtResponseDTO generateAndStoreToken(String provider, String email, String role) {
String redisKey = "RT(" + provider + "):" + email;
redisService.deleteValues(redisKey);
JwtResponseDTO tokenDto = jwtProvider.createToken(email, role);
redisService.setValuesWithTimeout(redisKey, tokenDto.getRefreshToken(), jwtProvider.getTokenExpirationTime(tokenDto.getRefreshToken()));
return tokenDto;
}
}
위 코드는 토큰을 생성하고 Redis에 데이터를 저장하는 로직인데 기존에 JWT 서비스에 작성했던 내용을 사용하려고
했더니 무한 참조 오류가 발생하여 별도의 클래스로 빼서 관리했다.
✅ CustomFailHandler
package toy.shop.service.oauth;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import toy.shop.cmmn.exception.ConflictException;
import java.io.IOException;
import java.net.URLEncoder;
@Component
public class CustomFailHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, org.springframework.security.core.AuthenticationException exception) throws IOException, ServletException {
String errorMessage = null;
// 예외 메시지를 확인하여 적절한 메시지를 설정
if (exception instanceof OAuth2AuthenticationException) {
OAuth2AuthenticationException oauthException = (OAuth2AuthenticationException) exception;
errorMessage = oauthException.getMessage();
if ("conflict".equals(oauthException.getError().getErrorCode())) {
errorMessage = oauthException.getMessage();
} else if ("unsupported_social_login".equals(oauthException.getError().getErrorCode())) {
errorMessage = oauthException.getMessage();
}
}
// 에러 메시지를 클라이언트에 전달하기 위해 리디렉트
response.sendRedirect("http://localhost:3000/error?message=" + URLEncoder.encode(errorMessage, "UTF-8"));
}
}
위 코드는 로그인에 실패하였을 경우 클라이언트에게 에러 내용을 보내기 위하여 작성한 코드이다.
지금 생각하니 리다이렉트 url을 yml파일로 관리해야 하는데 왜 하드코딩을 했지?...
✅ 마무리
테스트를 진행했을 때 문제없이 잘 로그인되는 것을 확인했다.
그런데 분명 스크린샷을 찍어뒀는데 사진이 없어진 관계로 테스트 항목은 버리도록 하겠다...
'Java > 쇼핑몰' 카테고리의 다른 글
쇼핑몰 7 - [이메일로 비밀번호 변경 URL 전송] (0) | 2024.12.13 |
---|---|
쇼핑몰 - 5 [spring security + JWT + Redis 로그인] (0) | 2024.11.19 |
쇼핑몰 - 4 [회원 가입 구현] (0) | 2024.11.12 |
쇼핑몰 - 3 [GitHub Actions를 통한 CI/CD] (3) | 2024.11.06 |
쇼핑몰 - 2 [Docker-compose 사용하기] (0) | 2024.11.03 |