-
[Spring Security] OAuth2 소셜 로그인 (JWT 방식)Spring Framework 2024. 5. 20. 18:02
참고 자료
- https://www.youtube.com/watch?v=xsmKOo-sJ3c&list=PLJkjrxxiBSFALedMwcqDw_BPaJ3qqbWeB&index=1
- https://substantial-park-a17.notion.site/OAuth2-JWT-2c0ed188191f48bc8f1f45b73eef4f65
이전 실습에서 심화된 부분이 있습니다.
[Spring Security] OAuth2 소셜 로그인 (세션 방식)
목표Spring Security와 OAuth2 세션 방식을 사용하여 구글, 네이버, 카카오 소셜 로그인을 구현한다.로그인 사용자 정보를 MySQL 데이터베이스에 저장한다.authorization-grant-type : authorization_code 방식을
jngsngjn.tistory.com
[Spring Security] JWT 간단히 실습해보기
참고 자료더보기https://www.youtube.com/watch?v=NPRh2v7PTZg&list=PLJkjrxxiBSFCcOjy0AAVGNtIa08VLk1EJ&index=1https://substantial-park-a17.notion.site/JWT-7a5cd1cf278a407fae9f35166da5ab03https://jwt.io목표스프링 시큐리티와 JWT를 사용하여
jngsngjn.tistory.com
목표
- CSR(클라이언트 사이드 렌더링) 방식에서 OAuth2를 이용한 구글, 네이버 소셜 로그인 시 서버에서 JWT를 생성하여 클라이언트(웹 브라우저)에게 발급해 본다.
- 이후 클라이언트가 JWT를 포함하여 요청 시 서버는 JWT를 검증하고 응답한다.
- OAuth2 인증 방식으로 Code Grant 방식을 사용한다.
초기 설정
DB 설정
더보기@Data @Entity public class UserEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String username; private String name; private String email; private String role; }
public interface UserRepository extends JpaRepository<UserEntity, Long> { UserEntity findByUsername(String username); }
SecurityConfig
@Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; private final CustomSuccessHandler customSuccessHandler; private final JWTUtil jwtUtil; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(auth -> auth.disable()); http.formLogin(auth -> auth.disable()); http.httpBasic(auth -> auth.disable()); http.oauth2Login(Customizer.withDefaults()); http.authorizeHttpRequests(auth -> auth .requestMatchers("/").permitAll() .anyRequest().authenticated()); http.sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); return http.build(); } }
OAuth2Response 구현
- 소셜 로그인 서비스별로 제공하는 응답 형식이 다르기 때문에 인터페이스를 하나 정의하고 구현하도록 한다.
더보기public interface OAuth2Response { String getProvider(); String getProviderId(); String getEmail(); String getName(); }
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(); } }
@RequiredArgsConstructor public class GoogleResponse implements OAuth2Response { private final Map<String, Object> 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(); } }
OAuth2UserService 구현
UserDTO
@Data public class UserDTO { private String role; private String name; private String username; }
CustomOAuth2User
@RequiredArgsConstructor public class CustomOAuth2User implements OAuth2User { private final UserDTO userDto; // 각 서비스마다 반환하는 attribute 형태가 달라서 사용 X @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(); } }); return collection; } @Override public String getName() { return userDto.getName(); } public String getUsername() { return userDto.getUsername(); } }
CustomOAuth2UserService
- 소셜 로그인 서비스에서 제공하는 사용자 정보는 OAuth2UserService로 도착하기 때문에 필수적으로 구현해줘야 한다.
- 리소스 서버로부터 얻어온 사용자 정보를 데이터베이스에 저장하여 관리한다.
@Service @RequiredArgsConstructor public class CustomOAuth2UserService extends DefaultOAuth2UserService { private final UserRepository userRepository; // OAuth2UserRequest : 리소스 서버에서 제공되는 유저 정보 @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { OAuth2User oAuth2User = super.loadUser(userRequest); System.out.println("oAuth2User = " + oAuth2User); String registrationId = userRequest.getClientRegistration().getRegistrationId(); OAuth2Response oAuth2Response = null; if (registrationId.equals("naver")) { oAuth2Response = new NaverResponse(oAuth2User.getAttributes()); } else if (registrationId.equals("google")) { oAuth2Response = new GoogleResponse(oAuth2User.getAttributes()); } else { return null; } String username = oAuth2Response.getProvider() + " " + oAuth2Response.getProviderId(); UserEntity existData = userRepository.findByUsername(username); if (existData == null) { UserEntity userEntity = new UserEntity(); userEntity.setUsername(username); userEntity.setName(oAuth2Response.getName()); userEntity.setEmail(oAuth2Response.getEmail()); userEntity.setRole("ROLE_USER"); userRepository.save(userEntity); UserDTO userDto = new UserDTO(); userDto.setUsername(username); userDto.setName(oAuth2Response.getName()); userDto.setRole("ROLE_USER"); return new CustomOAuth2User(userDto); } else { existData.setEmail(oAuth2Response.getEmail()); existData.setName(oAuth2Response.getName()); userRepository.save(existData); UserDTO userDTO = new UserDTO(); userDTO.setUsername(existData.getUsername()); userDTO.setName(oAuth2Response.getName()); userDTO.setRole(existData.getRole()); return new CustomOAuth2User(userDTO); } } }
JWT 발급 및 검증 클래스 구현
JWTUtil
@Component public class JWTUtil { private final SecretKey secretKey; public JWTUtil(@Value("${spring.jwt.secret}") String secret) { this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm()); } public String createJwt(String username, String role, Long expiredMs) { return Jwts.builder() .claim("username", username) .claim("role", role) .issuedAt(new Date(System.currentTimeMillis())) .expiration(new Date(System.currentTimeMillis() + expiredMs)) .signWith(secretKey) .compact(); } 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()); } }
SuccessHandler 구현
CustomSuccessHandler
- OAuth2 로그인이 성공하면 실행될 핸들러를 JWT 발급하도록 커스텀한다.
- 쿠키에 JWT를 담아 클라이언트측으로 리다이렉트한다.
@Component @RequiredArgsConstructor public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { private final JWTUtil jwtUtil; // 로그인 성공 시 호출 @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal(); String username = oAuth2User.getUsername(); String role = oAuth2User.getAuthorities().iterator().next().getAuthority(); String token = jwtUtil.createJwt(username, role, 60 * 60 * 1000L); response.addCookie(createCookie("Authorization", token)); response.sendRedirect("<http://localhost:3000/>"); } private Cookie createCookie(String key, String value) { Cookie cookie = new Cookie(key, value); cookie.setMaxAge(60 * 60 * 1000); // cookie.setSecure(true); // HTTPS 환경 cookie.setPath("/"); cookie.setHttpOnly(true); return cookie; } }
JWT 검증
JWTFilter
- 요청 쿠키에 JWT가 존재하는 경우 검증하고 SecurityContextHolder에 세션을 생성한다. 이 세션은 stateless로 관리되기 때문에 해당 요청이 끝나면 자동으로 소멸된다.
@RequiredArgsConstructor public class JWTFilter extends OncePerRequestFilter { private final JWTUtil jwtUtil; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authorization = null; Cookie[] cookies = request.getCookies(); for (Cookie cookie : cookies) { if (cookie.getName().equals("Authorization")) { authorization = cookie.getValue(); } } if (authorization == null) { filterChain.doFilter(request, response); return; } String token = authorization; if (jwtUtil.isExpired(token)) { filterChain.doFilter(request, response); return; } String username = jwtUtil.getUsername(token); String role = jwtUtil.getRole(token); UserDTO userDTO = new UserDTO(); userDTO.setUsername(username); userDTO.setRole(role); CustomOAuth2User oAuth2User = new CustomOAuth2User(userDTO); Authentication authToken = new UsernamePasswordAuthenticationToken(oAuth2User, null, oAuth2User.getAuthorities()); // 세션에 저장 SecurityContextHolder.getContext().setAuthentication(authToken); filterChain.doFilter(request, response); } }
CORS 설정
- 기본적으로 웹 브라우저는 동일 출처 정책(Same-Origin Policy)을 따르고 있다. 동일 출처 정책이란 로드된 문서나 스크립트가 다른 출처의 리소스와 상호작용하는 것을 제한하는 것을 말한다.
- CORS 설정을 통해 허용된 도메인 간의 리소스 공유를 가능하게 해야 프론트와 백 간의 리소스 공유가 가능해진다.
SecurityConfig
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.cors(corsCustomizer -> corsCustomizer.configurationSource(request -> { CorsConfiguration configuration = new CorsConfiguration(); // 허용할 출처 설정 configuration.setAllowedOrigins(Collections.singletonList("<http://localhost:3000>")); // 허용할 메서드 설정 configuration.setAllowedMethods(Collections.singletonList("*")); // 클라이언트에서 쿠키와 인증 정보를 포함한 요청 가능 configuration.setAllowCredentials(true); // 모든 요청 헤더 허용 configuration.setAllowedHeaders(Collections.singletonList("*")); configuration.setMaxAge(3600L); // 클라이언트에게 노출할 헤더 설정 configuration.setExposedHeaders(Collections.singletonList("Set-Cookie")); configuration.setExposedHeaders(Collections.singletonList("Authorization")); return configuration; })); }
CorsMvcConfig
@Configuration public class CorsMvcConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry corsRegistry) { // http://localhost:3000에서 오는 모든 요청에 대해 CORS 허용 corsRegistry.addMapping("/**") .exposedHeaders("Set-Cookie") .allowedOrigins("<http://localhost:3000>"); } }
SecurityConfig 최종
SecurityConfig
- CustomOAuth2UserService, CustomSuccessHandler, JWTFilter 등 설정
@Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; private final CustomSuccessHandler customSuccessHandler; private final JWTUtil jwtUtil; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.cors(corsCustomizer -> corsCustomizer.configurationSource(request -> { CorsConfiguration configuration = new CorsConfiguration(); // 허용할 출처 설정 configuration.setAllowedOrigins(Collections.singletonList("<http://localhost:3000>")); // 허용할 메서드 설정 configuration.setAllowedMethods(Collections.singletonList("*")); // 클라이언트에서 쿠키와 인증 정보를 포함한 요청 가능 configuration.setAllowCredentials(true); // 모든 요청 헤더 허용 configuration.setAllowedHeaders(Collections.singletonList("*")); configuration.setMaxAge(3600L); // 클라이언트에게 노출할 헤더 설정 configuration.setExposedHeaders(Collections.singletonList("Set-Cookie")); configuration.setExposedHeaders(Collections.singletonList("Authorization")); return configuration; })); http.csrf(auth -> auth.disable()); http.formLogin(auth -> auth.disable()); http.httpBasic(auth -> auth.disable()); http.addFilterBefore(new JWTFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); http.oauth2Login(oauth2 -> oauth2 .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig .userService(customOAuth2UserService)) .successHandler(customSuccessHandler)); http.authorizeHttpRequests(auth -> auth .requestMatchers("/").permitAll() .anyRequest().authenticated()); http.sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); return http.build(); } }
테스트
MyController
- /my 경로는 인증된 사용자만 접근 가능
@Slf4j @RestController public class MyController { @GetMapping("/my") public String myAPI(@AuthenticationPrincipal CustomOAuth2User user) { log.info("username={}", user.getUsername()); log.info("role={}", user.getAuthorities().iterator().next().getAuthority()); return "my route"; } }
리액트 App.js
import React from 'react'; const onNaverLogin = () => { window.location.href = "<http://localhost:8080/oauth2/authorization/naver>"; }; const redirectToMy = () => { fetch("http://localhost:8080/my", { method: "GET", credentials: "include" }) .then((res) => res.text()) .then((data) => { alert(data) }) .catch((error) => alert(error)) }; function App() { return ( <div className="App"> <h1>Welcome</h1> <button onClick={onNaverLogin}>Naver Login</button> <button onClick={redirectToMy}>Go to /my</button> </div> ); } export default App;
첫 화면
로그인 전 /my 경로 접근
로그인 요청 시 로그인 페이지 응답
로그인 이후 /my 경로 접근
이번 실습도 어려웠다. 맨날 어려운 것 같다. 그동안 공부할 때 서버 사이드 렌더링 방식으로만 공부를 해서 클라이언트 사이드 렌더링에 대해 아는 것이 없었지만 이번 실습을 통해서 CSR에 대해 조금이나마 배울 수 있었다. 그래서 CORS에 대해서도 처음 알게되었다. 원활한 협업을 위해서 CSR 방식에 대해서도 알고 있어야겠구나 라는 생각이 들었다. 또한 이 방식에서 JWT를 어떻게 처리해야 하는지도 알게 되었다. 백엔드측에 JWT 발급 책임을 주는 것이 안전한 방식이었다.
더 열심히 해야겠다.. 아직 모르는 게 너무 많다!
'Spring Framework' 카테고리의 다른 글
[Spring Security] OAuth2 소셜 로그인 중복 사용자 검증 (0) 2024.05.22 [Spring Security] JWT - Refresh 토큰 발급 (0) 2024.05.21 [Spring Security] JWT 간단히 실습해보기 (1) 2024.05.19 [Spring Security] OAuth2 소셜 로그인 (세션 방식) (0) 2024.05.17 [Spring Security] 로그인 실패 시 계정 잠금 (0) 2024.05.16