PPAK

[Spring] Spring Security 에서 JWT 를 통한 인증/인가 수행하기 본문

spring

[Spring] Spring Security 에서 JWT 를 통한 인증/인가 수행하기

PPakSang 2022. 8. 7. 11:43

기존에 스프링 시큐리티에 대해서 잠깐 공부하고 간단하게 실습해본 것이 전부인 상태에서  이번에 진행하는 프로젝트에 한번 적용을 해보려고 합니다.

 

기본적으로 스프링 시큐리티는 애플리케이션에서 인증/인가 에 대한 설정을 편리하게 도와주는 역할을 합니다.

 

Controller 에서 인증 인가를 충분히 수행할 수 있지만 관심사의 분리 측면에서 역할이 확실히 구분됩니다.

1. Controller 는 사용자의 요청에 대한 서비스의 응답을 구성한다.

2. 인증과 인가는 Controller 까지 요청이 오지 않고도 충분히 수행할 수 있다.

 

따라서 Dispatcher Servelet 에 요청이 돌아오기 전에 인증과 인가를 수행하는 레이어를 스프링 시큐리티가 담당한다고 볼 수 있습니다.

위 사진은 전체적인 스프링 시큐리티의 인증/인가 절차를 보여주는 사진입니다.

 

Filter 가 하나의 영역에서 인증 인가를 수행하는 단위라고 보면 되고, 이번 프로젝트에서는 사용자 토큰 검증을 통한 인증/인가 를 수행하는 JWT Filter 를 만들 예정입니다.

 

 

스프링 시큐리티는 이러한 필터들을 또 다시 하나로 묶어 하나의 인증 영역을 만들고 이를 SecurityFilterChain 객체로 관리합니다.

 

필터를 생성하며 확인할 것이지만 스프링 시큐리티는 DelegatingFilterProxy 로 오는 요청 URL 을 통해서 어떤 SecurityFilterChain 을 사용할지 결정을 합니다.

 

기존에는 하나의 SecurityFilterChain 을 구성하기 위해서 WebSecurityConfigurerAdapter 를 상속한 클래스에서 configue() 메소드를 오버라이딩 하는 방식으로 생성하였습니다.

 

하지만 Spring Security 5.7.1 부터는 SecurityFilterChain 을 직접 Bean 으로 등록해서 사용하는 방식을 권고하고 있습니다.

WebSecurityConfigurerAdapter is deprecated 

 

위 본문에서도 알 수 있듯 컴포넌트 단위로 SecurityFilterChain 을 관리하기 위해서 변경이 되었습니다.

아래는 변경된 정책을 바탕으로 구현한 코드 입니다.

SecurityConfigure

@EnableWebSecurity
public class SecurityConfigure {

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

    JwtAuthenticationFilter jwtAuthenticationFilter(JwtProvider jwtProvider, CookieUtil cookieUtil) {
        return new JwtAuthenticationFilter(jwtProvider, cookieUtil);
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http,
                                           JwtProvider jwtProvider,
                                           CookieUtil cookieUtil) throws Exception {
        return http
                .httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/api/v1/**").permitAll()
                .antMatchers("/test").hasRole("USER")
                .antMatchers("/api/user/**").hasRole("USER")
                .and()
                .addFilterBefore(jwtAuthenticationFilter(jwtProvider, cookieUtil),
                        UsernamePasswordAuthenticationFilter.class)
                .build();
    }
}

 

1. 스프링 시큐리티에서 제공하는 패스워드 암호화를 수행하는 객체를 Bean 으로 등록

2. 이번 SecurityFilterChain 에 추가할 JwtAuthenticationFilter 를 생성 (JwtProvider, CookieUtil 등의 필요한 의존성을 주입해줍니다)

위와 같이 Bean 을 등록할 때 의존성 주입이 되는 것을 이용하여 편리하게 필터 객체를 생성합니다.
이전에 비해 직접 SecurityFilterChain 을 생성하니 직관적인 의존성 주입이 가능한 것 같습니다.

 

JWT Filter

 

Filter 는 Request 에 포함된 쿠키에서 토큰을 추출하고, 토큰의 payload 에 존재하는 email 을 바탕으로 인증에 필요한 토큰(UsernamePasswordToken) 을 생성합니다.

 

위 토큰은 JWT 의 토큰과 관련이 없습니다. 시큐리티 인증 로직에서 필요한 하나의 오브젝트로 볼 수 있습니다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtProvider jwtProvider;
    private final CookieUtil cookieUtil;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        String token = null;
        Authentication authenticate;

        HttpServletRequest req = (HttpServletRequest)request;
        HttpServletResponse res = (HttpServletResponse)response;

        Cookie accountTokenCookie = cookieUtil.getCookie(req, "account_token");
        if (accountTokenCookie != null) {
            token = accountTokenCookie.getValue();
        }

        if(token != null && !jwtProvider.isTokenExpired(token)) {
            try {
                String emailFromToken = jwtProvider.getEmailFromToken(token);
                authenticate = jwtProvider
                        .authenticate(new UsernamePasswordAuthenticationToken(emailFromToken, ""));

                SecurityContextHolder.getContext().setAuthentication(authenticate);
            } catch(Exception e) {
                res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                res.setContentType("application/json");
                res.setCharacterEncoding("UTF-8");

                JSONObject resJson = new JSONObject();
                resJson.put("code", 401);
                resJson.put("message", e.getMessage());

                res.getWriter().write(resJson.toString());
            }
        }

        chain.doFilter(request, response);
    }
}

 

 

원래는 인증 토큰을 AuthenticationManager 를 호출하여 해당 토큰의 인증을 수행할 수 있는 Provider 를 찾아 전달해 인증(Authenticate) 을 수행하지만 본 프로젝트에서는 jwt 의 경우 하나의 토큰을 단 하나의 Provider 가 인증을 수행하면 될 것으로 판단되어 Filter 에 Provider 를 직접 주입하는 방식으로 구현하였습니다.

 

필터는 기본 GenericFilterBean 을 상속받았고  doFilter() 메소드를 구현해줍니다.

메소드 내부에서는 토큰에서 이메일 값을 추출하고, 토큰을 생성하여 Provider 에게 인증을 요청하는 역할을 합니다.

 

추후에 수정할 수도 있지만 현재는 쿠키와 토큰에 대한 유효성 검증에 따라서 응답 로직을 Filter 에 포함해둔 상태입니다.

 

어찌저찌 인증이 마무리 되었다면 해당 Authentication(인증이 완료되고 해당 이메일에 따른 권한이 부여된 객체) 객체를 

SecurityContext 에 저장합니다

 

나중에 SecurityContext 에 저장된 인증정보(Authentication 객체) 를 바탕으로 요청을 인가할지 말지를 결정합니다.

 

JwtProvider

JwtProvider 는 인증 간 필요한 token 관련 유틸 메소드를 제공하고, AccountDetailsService 를 호출하여 인증을 수행하고, 그 결과로 UserDetails 객체를 넘겨 받는다.

 

UserDetailsService 에서 같은 Authentication 클래스를 쓰지 않는 이유는 역시 관심사의 분리때문이라고 생각한다.

Authentication 은 인증/인가에만 필요한 정보를 담고있는 반면 UserDetails 는 실제 데이터베이스에 매핑되는 데이터를 포함하고 있다고 볼 수 있다.

@Component
@RequiredArgsConstructor
public class JwtProvider implements AuthenticationProvider {

    private final AccountDetailsService accountDetailsService;

    private static final long TOKEN_VALIDATION_SECOND = 1000L * 60 * 120;
    private static final long REFRESH_TOKEN_VALIDATION_TIME = 1000L * 60 * 60 * 48;


    @Value("${spring.jwt.secret}")
    private String SECRET_KEY;

    @Value("${group.name}")
    private String ISSUER;

    private Algorithm getSigningKey(String secretKey) {
        return Algorithm.HMAC256(secretKey);
    }

    private Map<String, Claim> getAllClaims(DecodedJWT token) {
        return token.getClaims();
    }

    public String getEmailFromToken(String token) {
        DecodedJWT verifiedToken = validateToken(token);
        return verifiedToken.getClaim("email").asString();
    }

    private JWTVerifier getTokenValidator() {
        return JWT.require(getSigningKey(SECRET_KEY))
                .withIssuer(ISSUER)
                .build();
    }

    public String generateToken(Map<String, String> payload) {
        return doGenerateToken(TOKEN_VALIDATION_SECOND, payload);
    }

    public String generateRefreshToken(Map<String, String> payload) {
        return doGenerateToken(REFRESH_TOKEN_VALIDATION_TIME, payload);
    }

    private String doGenerateToken(long expireTime, Map<String, String> payload) {

        return JWT.create()
                .withIssuedAt(new Date(System.currentTimeMillis()))
                .withExpiresAt(new Date(System.currentTimeMillis() + expireTime))
                .withPayload(payload)
                .withIssuer(ISSUER)
                .sign(getSigningKey(SECRET_KEY));
    }

    private DecodedJWT validateToken(String token) throws JWTVerificationException {
        JWTVerifier validator = getTokenValidator();
        return validator.verify(token);
    }

    public boolean isTokenExpired(String token) {
        try {
            DecodedJWT decodedJWT = validateToken(token);
            return false;
        } catch (JWTVerificationException e) {
            return true;
        }
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        AccountDetails userDetails = (AccountDetails) accountDetailsService.loadUserByUsername
                ((String) authentication.getPrincipal());


        return new UsernamePasswordAuthenticationToken(
                userDetails.getEmail(),
                userDetails.getPassword(),
                userDetails.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return false;
    }
}

 

UserDetailsService 와 UserDetails 구현체는 생각보다 간단하다.

 

UserDetailsService 는 username 을 바탕으로 실제 존재하는 유저 정보를 불러와 UserDetails 객체로 반환하는 메소드인 loadUserByUsername() 을 구현해야한다.

 

Security 에서 다루는 유저 정보(UserDetails)와 실제 Domain Entity 사이에서도 어느 정도 차이가 있을 수 있기 때문에 따로 필요한 데이터만을 이용해서 UserDetails 객체를 생성한다.

@Service
@RequiredArgsConstructor
public class AccountDetailsService implements UserDetailsService {

    private final AccountRepository accountRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        Account account = accountRepository.findAccountByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException("해당 이메일과 일치하는 계정이 없습니다."));

        return new AccountDetails(account);
    }
}
public class AccountDetails extends User {

    public AccountDetails(Account account) {
        super(account.getEmail(), account.getPassword(),
                AuthorityUtils.createAuthorityList(account.getRole().toString()));
    }

    public String getEmail() {
        return this.getUsername();
    }
}

다시 UserDetailsService -> JwtProvider 에서 인증된 Authenticaiton 객체 생성 -> JwtAuthenticationFilter 로 전달 후 SecurityContext 에 저장하면 해당 필터가 수행하는 작업은 모두 종료된다.

 

 

잘못된 정보가 있다면 댓글로 알려주시면 감사하겠습니다!!

Comments