ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Security] OAuth2 소셜 로그인 (JWT 방식)
    Spring Framework 2024. 5. 20. 18:02

    참고 자료


    이전 실습에서 심화된 부분이 있습니다.

     

    [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 발급 책임을 주는 것이 안전한 방식이었다.

     

    더 열심히 해야겠다.. 아직 모르는 게 너무 많다!