Project/React+Java

6. Spring Boot + Spring Security + JWT + access token

건담아빠 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