ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 6. Spring Boot + Spring Security + JWT + access token
    Project/React+Java 2022. 7. 22. 14:29

     

    https://github.com/dchkang83/project-board

     

    GitHub - dchkang83/project-board

    Contribute to dchkang83/project-board development by creating an account on GitHub.

    github.com

     

    jwt 및 security를 최신 버전으로 설정하다 보니 deprecated 된 class 및 function 들이 많아서 설정하는데 애를 먹었다.!!

    자세한 소스들은 깃을 참조하기 바란다.

     

    1. boot starter security - 의존성 추가

    implementation 'org.springframework.boot:spring-boot-starter-security'
    testImplementation 'org.springframework.security:spring-security-test'


    위와 같이 의존성만 추가하여도 시큐리티의 기본설정을 따르게 됨으로 아래 이미지와 같이 로그인 화면이 뜬다.
    (로그인 폼 참의 URL도 기본설정을 따름으로 이후에 config 설정을 통해서 변경하면 된다.)


    현재 상태에서 로그인 하려면 Username 은 user로 입력하고, Password는 아래 사진과 같이 어플리케이션 시작 로그에 표시되는 패스워드를 사용하면 된다.

     

    2. JWT - 의존성 추가

    implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'

     

    3. JWT key 설정

    # application.yml
        jwt:
          secret: ThisIsA_SecretKeyForJwtExampleThisIsA_SecretKeyForJwtExampleThisIsA_SecretKeyForJwtExampleThisIsA_SecretKeyForJwtExample
    
    
    # WeSecurityConfig.java
        @Value("${jwt.secret}")
        private String secret;
    
        @Bean
        public JwtTokenProvider jwtTokenProvider() {
            return new JwtTokenProvider(secret);
        }
        
        @Bean
        protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
            ...
            // login 주소가 호출되면 인증 및 토큰 발행 필터 추가
            http.addFilterBefore(new JwtAuthenticationFilter(authenticationManager(), jwtTokenProvider()), UsernamePasswordAuthenticationFilter.class);
    
            // jwt 토큰 검사
            http.addFilterBefore(new JwtAuthorizationFilter(userRepository, jwtTokenProvider()), UsernamePasswordAuthenticationFilter.class);
            ...
        }

     

    4. 주요 소스 정리

    - WebSecurityConfig

    @Configuration
    @EnableWebSecurity
    @RequiredArgsConstructor
    @Slf4j
    public class WebSecurityConfig {
        private final CorsFilter corsFilter;
        private final UserRepository userRepository;
    
        private final AuthenticationConfiguration authenticationConfiguration;
    
        @Value("${jwt.secret}")
        private String secret;
    
        @Bean
        public AuthenticationManager authenticationManager() throws Exception 
        {
            return authenticationConfiguration.getAuthenticationManager();
        }
    
        @Bean
        public PasswordEncoder passwordEncoder() {        
            return new BCryptPasswordEncoder();
        }
    
        @Bean
        public JwtTokenProvider jwtTokenProvider() {
            return new JwtTokenProvider(secret);
        }
    
        @Bean
        protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
            http
                    .csrf()
                    .disable()
                    .sessionManagement()
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                    .and()
                    .addFilter(corsFilter) // Controller 어노테이트 @CrossOrigin(인증 X), 시큐리티 필터에 등록 인증(O)
                    .formLogin()
                    // .loginProcessingUrl("/login") // /login 주소가 호출이 되면 시큐리티가 낚아채서 대신 로그인을 진행해준다.
                    .and()
                    .httpBasic().disable()
    
                    .authorizeRequests()
                    // .antMatchers("/signin").permitAll() // 컨트롤러에서 인증 하는 부분 테스트
    
                    .antMatchers("/api/v1/user/**")
                    .access("hasRole('ROLE_USER') or hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
                    .antMatchers("/api/v1/manager/**")
                    .access("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
                    .antMatchers("/api/v1/admin/**")
                    .access("hasRole('ROLE_ADMIN')")
                    .anyRequest().permitAll();
    
            // login 주소가 호출되면 인증 및 토큰 발행 필터 추가
            http.addFilterBefore(new JwtAuthenticationFilter(authenticationManager(), jwtTokenProvider()), UsernamePasswordAuthenticationFilter.class);
    
            // jwt 토큰 검사
            http.addFilterBefore(new JwtAuthorizationFilter(userRepository, jwtTokenProvider()), UsernamePasswordAuthenticationFilter.class);
    
            // TODO. 왜 추가 했는지 잘 기억이가 안남. 확인이 필요함
            // http.authenticationProvider(authenticationProvider());
    
            return http.build();
        }
    
        @Bean
        public UserDetailsService userDetailsService() {
            return new PrincipalDetailsService(userRepository);
        }
    
        @Bean
        public DaoAuthenticationProvider authenticationProvider() {
            DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
    
            authProvider.setUserDetailsService(userDetailsService());
            authProvider.setPasswordEncoder(passwordEncoder());
            return authProvider;
        }
    }

    -  JwtAuthenticationFilter

    @Slf4j
    @RequiredArgsConstructor
    public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    
        private final AuthenticationManager authenticationManager; // @Autowired
        private final JwtTokenProvider jwtTokenProvider;
    
        // /login 요청을 하면 로그인 시도를 위해서 실행되는 함수
        @Override
        public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
            log.info("{} - attemptAuthentication -> 로그인 시도중", this.getClass());
    
            // request에 있는 username과 password를 파싱해서 자바 Object로 받기
            ObjectMapper om = new ObjectMapper();
    
            try {
                /*
                log.info(request.getInputStream().toString());
                BufferedReader br = request.getReader();
                String input = null;
                while((input = br.readLine()) != null) {
                    log.info(input);
                }
                */
                User user = om.readValue(request.getInputStream(), User.class);
                UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
    
                // PrincipalDetailsService의 loadUserByUsername 함수가 실행된 후 정상이면 authentication이 리턴됨
                // DB에 있는 username과 password가 일치한다.
                Authentication authentication = authenticationManager.authenticate(authenticationToken);
    
                // 로그인이 되었다는 뜻.
                PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
                log.info(principalDetails.getUser().getUsername()); // 로그인 정상적으로 되었다는 뜻
    
                // authentication 객체가 session 영역에 저장을 해야하고 그방법이 return 해주면 됨.
                // 리턴의 이유는 권한 관리를 security가 대신 해주기 때문에 편하려고 하는거임
                // 굳이 JWT토큰을 사용하면서 세션을 만들 이유가 없음. 근데 단지 권한 처리때문에 session에 넣어준다.
                return authentication;
            } catch (Exception e) {
                e.printStackTrace();
            }
    
            return null;
        }
    
        // attemptAuthentication실행 후 인증이 정상적으로 되었으면 successfulAuthentication 함수가 실행됨
        // JWT 토큰을 만들어서 request 요청한 사용자에게 JWT 토큰을 response 해주면 됨
        @Override
        protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
            log.info("{} - successfulAuthentication -> 인증 완료", this.getClass());      
    
            String jwtToken = jwtTokenProvider.generateToken(authResult);
            log.info("jwtToken : {}", jwtToken);
    
            response.addHeader("Authorization", "Bearer " + jwtToken);
        }
    }

    - JwtAuthorizationFilter

    // 시큐리티가 filter 가지고 있는데 그 필터중에 BasicAuthenticationFilter 라는 것이 있음.
    // 권한이나 인증이 필요한 특정 주소를 요청했을 때 위 필터를 무조건 타게 되어있음.
    // 만약에 권한이 인증이 필요한 주소가 아니라면 이 필터를 안탐
    @Slf4j
    @RequiredArgsConstructor
    public class JwtAuthorizationFilter extends OncePerRequestFilter {
    
        private final UserRepository userRepository;
        private final JwtTokenProvider jwtTokenProvider;
    
        // public JwtAuthorizationFilter(UserRepository userRepository, JwtTokenProvider jwtTokenProvider) {
        //     this.userRepository = userRepository;
        //     this.jwtTokenProvider = jwtTokenProvider;
        // }
    
        /**
         * 인증이나 권한이 필요한 주소요청이 있을 때 해당 필터를 타게됨
         */
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
            log.info("{} - successfulAuthentication -> 인증이나 권한이 필요한 주소 요청이 됨", this.getClass());
            
            String bearerToken = request.getHeader("Authorization");
            if (bearerToken == null || bearerToken.startsWith("Bearer") == false) {
                chain.doFilter(request, response);
                return;
            }
    
            String jwtToken = bearerToken.substring("Bearer ".length());
            String username = jwtTokenProvider.getUsernameFromJWT(jwtToken);
            
            // 서명이 정상적으로 됨
            if (username != null) {
                User userEntity = userRepository.findByUsername(username);
                PrincipalDetails principalDetails = new PrincipalDetails(userEntity);
    
                // Jwt 토큰 서명을 통해서 서명이 정상이면 Authentication 객체를 만들어 준다.
                Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());
    
                // 강제로 시큐리티의 세션에 접근하여 Authentication 객체를 저장.
                SecurityContextHolder.getContext().setAuthentication(authentication);
                chain.doFilter(request, response);
            }
        }
    }

    - JwtTokenProvider

    @Slf4j
    public class JwtTokenProvider {
        // 토큰 유효시간
        // private final int JWT_EXPIRATION_MS = 604800000;
        private final int JWT_EXPIRATION_MS = 60000 * 1; // 만료 시간 세팅 : 60000 (1분) * 10 => 10분
        private final Key key;
    
        public JwtTokenProvider(String secret) {
            byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(secret);
            this.key = new SecretKeySpec(apiKeySecretBytes, SignatureAlgorithm.HS256.getJcaName());
        }
    
        /**
         * jwt 토큰 생성
         * 
         * @param authentication
         * @return
         */
        public String generateToken(Authentication authentication) {
            Date now = new Date();
            Date expiryDate = new Date(now.getTime() + JWT_EXPIRATION_MS);
            // Date expiryDate = new Date(System.currentTimeMillis() + JWT_EXPIRATION_MS);
            // // 만료 시간 세팅 : 60000 (1분) * 10 => 10분
    
            PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
    
            /*
             * https://velopert.com/2389
             * iss: 토큰 발급자 (issuer)
             * sub: 토큰 제목 (subject)
             * aud: 토큰 대상자 (audience)
             * exp: 토큰의 만료시간 (expiraton), 시간은 NumericDate 형식으로 되어있어야 하며 (예: 1480849147370)
             * 언제나 현재 시간보다 이후로 설정되어있어야합니다.
             * nbf: Not Before 를 의미하며, 토큰의 활성 날짜와 비슷한 개념입니다. 여기에도 NumericDate 형식으로 날짜를 지정하며,
             * 이 날짜가 지나기 전까지는 토큰이 처리되지 않습니다.
             * iat: 토큰이 발급된 시간 (issued at), 이 값을 사용하여 토큰의 age 가 얼마나 되었는지 판단 할 수 있습니다.
             * jti: JWT의 고유 식별자로서, 주로 중복적인 처리를 방지하기 위하여 사용됩니다. 일회용 토큰에 사용하면 유용합니다. (id)
             */
            // 표준 클레임 셋팅
            JwtBuilder builder = Jwts.builder()
                    .setId(String.valueOf(principalDetails.getUser().getUserNo())) // jti: JWT의 고유 식별자로서, 주로 중복적인 처리를 방지하기 위하여 사용됩니다. 일회용 토큰에 사용하면 유용합니다.
                    .setIssuedAt(now) // iat: 토큰이 발급된 시간 (issued at), 이 값을 사용하여 토큰의 age 가 얼마나 되었는지 판단 할 수 있습니다. - 현재시간 기반으로 생성
                    .setSubject(principalDetails.getUser().getUsername()) // sub: 토큰 제목 (subject) - 사용자
                    .setIssuer("gundam.com") // iss: 토큰 발급자 (issuer)
                    .signWith(key, SignatureAlgorithm.HS512) // 사용할 암호화 알고리즘, signature에 들어갈 secret 값 세팅
                    .setExpiration(expiryDate)  // exp: 토큰의 만료시간 (expiraton), 시간은 NumericDate 형식으로 되어있어야 하며 (예: 1480849147370), 언제나 현재 시간보다 이후로 설정되어있어야합니다.
            ;
    
            return builder.compact();
        }
    
        /**
         * Jwt 토큰에서 유져이름 추출
         * 
         * @param jwtToken
         * @return
         */
        public String getUsernameFromJWT(String jwtToken) {
            Jws<Claims> claims = Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(jwtToken);
    
            return claims.getBody().getSubject();
        }
    }

     



    9. 이슈

       9.1. WebSecurityConfigurerAdapter deprecated (Spring Security - How to Fix WebSecurityConfigurerAdapter Deprecated)

      참조: https://www.codejava.net/frameworks/spring-boot/fix-websecurityconfigureradapter-deprecated

    댓글

Designed by Tistory.