[서버개발캠프] Spring boot + Spring security + Refresh JWT + Redis + JPA 3편

Sieun Sim·2020년 1월 20일
4

서버개발캠프

목록 보기
7/21

깃헙 주소

https://github.com/tlatldms/boot-security-jwt-redis

Spring Security에서 @Secured 사용하기

@Secured annotation으로 간편하게 Role별로 설정하려면 Security Config file에 옵션을 추가해주어야 한다.
@EnableGlobalMethodSecurity(ecuredEnabled = true) 어노테이션이 포인트.

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) //
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtRequestFilter jwtRequestFilter;

    @Autowired
    private UserDetailsService jwtUserDetailsService;
    @Autowired
    private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

    @Override
    public void configure(WebSecurity web) throws Exception
    {
        web.ignoring().antMatchers("/**", "/css/**", "/script/**", "image/**", "/fonts/**", "lib/**");
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception
    {

        http.
                csrf().disable().
                cors().and().
                authorizeRequests().
                antMatchers("/**").permitAll().
                anyRequest().authenticated()
                .and().exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint).and().sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        // Add a filter to validate the tokens with every request
        http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
    }


    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        // configure AuthenticationManager so that it knows from where to load
        // user for matching credentials
        // Use BCryptPasswordEncoder
        auth.userDetailsService(jwtUserDetailsService).passwordEncoder(passwordEncoder());
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

}

Controller에서는 @Secured("ROLE_ADMIN") 지정하면 ADMIN만 접근가능하다.

@Secured("ROLE_ADMIN")
    @GetMapping(path="/hello")
    public String hello() {
        return "hello~";
    }

WebSecurityConfigurerAdapter로 권한 설정하기

하지만 secured로 하나하나 설정하기보다는 라우팅으로 관리하는게 훨씬 편할 것 같다. 여러 가지 시도 후 드디어 다 적용되는 방법을 발견했는데 깔끔하게 and로 분기해주니까 적용이 된다.. 그리고 jwt 토큰이 필요없는 경우 filter 자체를 안먹이고 싶어서 interceptor라는걸 찾아봤는데 일단은 /newuser에 들어오는 요청에 대해서는 permitall 해주는걸로 합의를 봤다. 나중에 interceptor도 공부해보기로

@Override
    protected void configure(HttpSecurity http) throws Exception
    {

        http.
                httpBasic().disable().
                cors().and().
                csrf().disable().
                sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).
                and().
                    authorizeRequests().
                    antMatchers("/newuser/**").permitAll().
                and().
                    authorizeRequests().
                    antMatchers("/admin/**").hasRole("ADMIN").
                and().
                    authorizeRequests().
                    antMatchers("/user/**").hasRole("USER").
                and().
                    authorizeRequests().
                    anyRequest().
                    authenticated().
                and().
                    exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint).
                and().
                    addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
    }

DeleteBy 사용시 Error 해결하기

repository를 통한 삭제 요청에 No EntityManager with actual transaction available for current thread... Error 만났을 때 해당 method 위에 @Transactional annotation을 붙이니 잘 동작했다. 정확한 이유는 더 알아보기

Refresh controller 작성

refresh의 로직은 크게 client가 expired 응답을 받고 refresh 해주는 컨트롤 메서드 url로 refresh url을 보냈다고 가정했다.

  1. 쿠키 제거나 로그아웃 등으로 refresh token이 로컬에 없는데 있다고 착각하고 요청을 보내면 없다고 알려준다.
  2. refresh token이 정상적으로 왔으면 client가 함께 보낸 expired된 access token에서 username을 꺼낸다.
  3. redis에 username을 key로 저장해뒀던 refresh token을 꺼내서 비교해본다.
  4. expired 되지는 않았는지 확인한다.
  5. 위의 조건이 모두 맞으면 User DB에 접근하는 loadUserByUsername 메소드를 이용해서 다시 access token을 만들어준다.

5번의 경우 나는 아직 username밖에 담고있지 않기 때문에 잘 변하지 않는 정보라서 DB에 접근하지 않고 expired access token의 정보로 만들어도 상관은 없다. 하지만 보통 다른 정보들도 있다고 가정했을 때 갱신해주는게 맞는 것 같아서 User DB에 직접 접근했다.

근데 정말 예외처리할게 많다. 이 메소드만큼은 처음부터 끝까지 스스로 짜서 뿌듯하다ㅠ 먼저 Jwts parser를 사용해 JWT를 파싱하는 함수에

private Claims getAllClaimsFromToken(String token) {
        return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
    }

token 자체를 잘못 넘기면 IllegalArgumentException, 파싱은 했는데 만료가 되면 ExpiredJwtException 를 반환한다. 이 에러를 직접 처리해줘야 하는데, ExpiredJwtException 객체에서

try {
                username = jwtTokenUtil.getUsernameFromToken(accessToken);
            } catch (IllegalArgumentException e) {

            } catch (ExpiredJwtException e) { //expire됐을 때
                username = e.getClaims().getSubject();
                logger.info("username from expired access token: " + username);
            }

ExpiredJwtException.getClaims()로 만료된 토큰의 payload를 가져올 수 있다. 원래 공개 된 정보니까. 직접 파싱하려고 했는데 이미 메소드가 있었다. 에러에서 직접 어떤 값을 꺼내보는건 처음인데 앞으로 항상 생각하고 있어야겠다.

—전체 코드—

@PostMapping(path="/newuser/refresh")
    public Map<String, Object>  requestForNewAccessToken(@RequestBody Map<String, String> m) {
        String accessToken = null;
        String refreshToken = null;
        String refreshTokenFromDb = null;
        String username = null;
        Map<String, Object> map = new HashMap<>();
        try {
            accessToken = m.get("accessToken");
            refreshToken = m.get("refreshToken");
            logger.info("access token in rnat: " + accessToken);
            try {
                username = jwtTokenUtil.getUsernameFromToken(accessToken);
            } catch (IllegalArgumentException e) {

            } catch (ExpiredJwtException e) { //expire됐을 때
                username = e.getClaims().getSubject();
                logger.info("username from expired access token: " + username);
            }

            if (refreshToken != null) { //refresh를 같이 보냈으면.
                try {
                    ValueOperations<String, Object> vop = redisTemplate.opsForValue();
                    Token result = (Token) vop.get(username);
                    refreshTokenFromDb = result.getRefreshToken();
                    logger.info("rtfrom db: " + refreshTokenFromDb);
                } catch (IllegalArgumentException e) {
                    logger.warn("illegal argument!!");
                }
                //둘이 일치하고 만료도 안됐으면 재발급 해주기.
                if (refreshToken.equals(refreshTokenFromDb) && !jwtTokenUtil.isTokenExpired(refreshToken)) {
                    final UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                    String newtok =  jwtTokenUtil.generateAccessToken(userDetails);
                    map.put("success", true);
                    map.put("accessToken", newtok);
                } else {
                    map.put("success", false);
                    map.put("msg", "refresh token is expired.");
                }
            } else { //refresh token이 없으면
                map.put("success", false);
                map.put("msg", "your refresh token does not exist.");
            }

        } catch (Exception e) {
            throw e;
        }
        logger.info("m: " + m);

        return map;
    }
    

Refresh에 성공한 모습. 집에가서 할 일: 401 에러 말고 좀 더 이쁘게 처리하기

0개의 댓글