Spring Security OAuth2.0(kakao)
https://kimtahwn.tistory.com/58
이전의 OAuth2.0 글을 읽고 오시면 좋습니다.
Spring Security OAuth2.0을 사용하여 kakao 로그인 구현하기
이 방식은 프론트엔드에서 Authorization Code를 받아올 필요없이 백엔드에서 모든 OAuth 로그인을 처리하는 방식입니다.
Spring Security 에서는 쉽게 OAuth2를 사용할 수 있도록 지원해줍니다.
1. 로그인 요청의 EndPoint를 /oauth2/authorization/{provider} 로 하게 되면 Spring Security에서 요청을 가로채 로그인을 시작하고 인증서버로 리디렉션을 수행하는데 사용합니다.
ex) kakao login : /oauth/authorization/kakao
2. 리디렉션의 EndPoint를 /login/oauth2/code/{provider} 로 하게 되면 Authorization Code와 Access Token을 획득하기 위한 Redirect URI로 사용합니다.
ex) local 환경 : http://localhost:8080/login/oauth2/code/kakao
Spring Security OAuth를 사용할 때 흐름
- /oauth2/authorization/{provider} 로 OAuth 로그인 요청
- OAuth2LoginAuthenticationFilter가 요청을 가로채고 외부 인증 제공자에 의해 인증이 완료되면, 인증 제공자로부터 Authorization Code를 받음.
- OAuth2LoginAuthenticationProvider는 인증 제공자로부터 받은 Authorization Code를 사용하여 Access Token을 요청하고, Access Token을 받음.
- OAuth2UserService가 Access Token을 사용하여 인증 제공자로부터 사용자 정보를 요청하고, 사용자 정보를 받음.
- OAuth2UserService가 사용자 정보를 이용하여 OAuth2User 객체를 생성하여 DB에 조회
- OAuth2User 객체를 OAuth2AuthenticationProvider에 전달
- OAuth2AuthenticationProvider는 OAuth2User의 정보를 검증하여 인증을 완료
- OAuth2AuthenticationProvider는 인증된 OAuth2AuthenticationToken 객체를 반환
- OAuth2AuthenticationSuccessHandler가 호출 이후 인증 성공 후 추가 처리를 수행함 (예: JWT 토큰 생성)
- OAuth2AuthenticationToken 객체를 SecurityContext에 저장, 사용자에게 Redirect
SpringBoot 설정
1. build.gradle에 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
2. appilcation.yml 파일 설정 추가
spring:
security:
oauth2:
client:
registration:
kakao:
client-authentication-method: client_secret_post
client-name: kakao
client-id: ${KAKAO_CLIENT_ID}
client-secret: ${KAKAO_CLIENT_SECRET}
authorization-grant-type: authorization_code
provider: kakao
redirect-uri: ${KAKAO_REDIRECT_URI}
scope: profile_nickname, account_email // 동의항목
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: id
위의 구성을 사용한다면 애플리케이션이 위에 설명한 2개의 추가 엔드포인트를 지원하게 됩니다.
3.SecurityConfig
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig{
private final CorsFilter corsFilter;
private final CustumOauth2UserService custumOauth2UserService;
private final AuthenticationSuccessHandler authenticationSuccessHandler;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final AuthenticationFailHandler authenticationFailHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.addFilter(corsFilter)
.csrf(CsrfConfigurer::disable)
.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.httpBasic(HttpBasicConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/manager/**").hasAuthority("ROLE_MANAGER")
.anyRequest().permitAll()
).oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo -> userInfo.userService(custumOauth2UserService))
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailHandler)
).addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
4. OAuth2UserService
@Service
@RequiredArgsConstructor
public class CustumOauth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UsersRepository usersRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// 기본 OAuth2UserService 객체 생성
OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService = new DefaultOAuth2UserService();
OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest);
// 클라이언트 등록 id(kakao, google 등), 사용자 이름 속성 가져오기
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUserNameAttributeName();
// OAuth2User 의 정보로 OAuth2Attribute 객체를 생성
OAuth2Attribute oAuth2Attribute =
OAuth2Attribute.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
// email을 통해 중복 확인
String email = oAuth2Attribute.getEmail();
Users user = usersRepository.findByEmail(email);
if (user == null) {
user = oAuth2Attribute.toEntity();
usersRepository.save(user);
}
// 권한 설정
List<SimpleGrantedAuthority> authorities;
if (user.getAuthor() == Author.roleManager) {
authorities = Collections.singletonList(new SimpleGrantedAuthority("ROLE_MANAGER"));
} else {
authorities = Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"));
}
return new DefaultOAuth2User(
authorities,
oAuth2Attribute.getAttributes(),
oAuth2Attribute.getAttributeKey()
);
}
}
DefaultOAuth2UserService의 loadUser 메서드가 userRequest를 사용해 provider(예: Google, Kakao 등)로부터 사용자 정보를 가져옵니다.
5. OAuth2Attribute
@Builder
@Getter
public class OAuth2Attribute {
private Map<String, Object> attributes; // 사용자 속성 정보 담는 Map
private String attributeKey; // 사용자 속성의 키 값
private String email; // email로 중복 검사
private String name;
private String provider; // 제공자 정보
// 서비스에 따라 OAuth2Attribute 객체를 생성하는 메서드
static OAuth2Attribute of(String provider, String attributeKey,
Map<String, Object> attributes) {
return switch (provider) {
case "kakao" -> ofKakao("email", attributes);
//case "google" -> ofGoogle(provider, attributeKey, attributes);
//case "naver" -> ofNaver(provider, "id", attributes);
default -> throw new RuntimeException();
};
}
/*
* Kakao 로그인일 경우 사용하는 메서드, 필요한 사용자 정보가 kakaoAccount -> kakaoProfile 두번 감싸져 있어서,
* 두번 get() 메서드를 이용해 사용자 정보를 담고있는 Map을 꺼내야한다.
* */
private static OAuth2Attribute ofKakao(String attributeKey,
Map<String, Object> attributes) {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile");
return OAuth2Attribute.builder()
.name((String) kakaoProfile.get("nickname"))
.email((String) kakaoAccount.get("email"))
.provider("kakao")
.attributes(kakaoAccount)
.attributeKey(attributeKey)
.build();
}
public Users toEntity() {
return Users.builder()
.userName(name)
.email(email)
.author(Author.roleUser)
.build();
}
}
각 OAuth2 서비스에 따라 반환하는 사용자의 형태가 다르기 때문에 provider에 따라 구분하여 필요한 사용자의 정보를 가져와 처리를 하는 클래스입니다.
6. AuthenticationSucessHandler
@Slf4j
@Component
@RequiredArgsConstructor
public class AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtUtil jwtUtil;
private final UsersRepository usersRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
Map<String, Object> attributes = oAuth2User.getAttributes();
String email = (String) attributes.get("email");
Users users = usersRepository.findByEmail(email);
JwtToken jwtToken = jwtUtil.generatedToken(email, users.getAuthor());
String redirectUrl = "프론트서버?accessToken=" + jwtToken.getAccessToken();
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
}
}
인증이 성공되었을 때 호출되는 successHandler를 구현한 클래스입니다.
필요한 정보를 추출하여, JWT Access Token을 생성하여 쿼리파라미터로 담아 프론트서버에 리디렉션합니다.
프론트에서 localStorage에 JWT Token을 저장하여 사용하기 때문에 리디렉션 할 때 쿼리파라미터로 담아 전달했습니다.
public void createCookieAccessToken(String accessToken, HttpServletResponse httpServletResponse) throws
UnsupportedEncodingException {
ResponseCookie cookie = ResponseCookie.from("Authorization",
URLEncoder.encode("Bearer " + accessToken, "utf-8").replaceAll("\\+", "%20"))
.path("/")
.sameSite("None")
.httpOnly(false)
.secure(true)
.maxAge(604800)
.build();
httpServletResponse.addHeader("Set-Cookie", cookie.toString());
}
이외의 방식으로는 JWT Token을 생성할 때, Cookie에 넣어 전달하는 방법이 있습니다.
하지만 백엔드에서 "Set-Cookie" Header를 통해 Response 하게 된다면 해당 Cookie는 프론트에서 접근하거나 수정할 수 없습니다.
출처, 참고 :
https://docs.spring.io/spring-security/reference/servlet/oauth2/index.html