[Spring Security] JWT Security - Spring Boot (10)

반응형

이번 글에서는 Spring Boot App에서 JWT를 사용해 Authenticaiton과 Authorization을 수행하는 방법에 대해 알아보도록 하겠습니다.

1. JWT

JWT에 관한 내용은 이글을 참고하시길 바랍니다.

이번글에서는 하나의 Spring Boot App에서 Authenticatoin과 Authorization을 같이 수행하도록 하겠습니다.

1-1) sample application

이번글에서 사용할 RestController는 다음과 같습니다. JWT는 주로 API Server에 사용되므로, View 단은 다루지 않겠습니다.

Rest API

@RestController
@RequestMapping("api/public")
@RequiredArgsConstructor
@CrossOrigin
public class PublicRestApiController {

    private final UserRepository userRepository;

    // Available to all authenticated users
    @GetMapping("test")
    public String test1(){
        return "API Test 1";
    }

    // Available to managers
    @GetMapping("management/reports")
    public String reports(){
        return "API Test 2";
    }

    // Available to ROLE_ADMIN
    @GetMapping("admin/users")
    public List<User> allUsers(){
        return this.userRepository.findAll();
    }

}

• /api/public/test는 모든 로그인한 사용자에게 허용합니다.

• /api/public/management/reports는 로그인한 사용자 중 manager 권한을 가진 사용자에게만 허용하겠습니다.

• /api/public/admin/users는 로그인한 사용자 중 admin 권한을 가진 사용자에게만 허용하겠습니다.

• @CrossOrigin 어노테이션으로 cors를 허용합니다.

1-2) JWT dependency

다음으로 JWT를 사용하기위한 라이브러리를 dependency에 추가합니다.

implementation group: 'com.auth0', name: 'java-jwt', version: '3.1.0'

2. Authentication

JWT Authentication은 다음과 같이 구현할 수 있습니다.

2-1) JwtProperties

먼저 JWT Properties 정보를 담고 있는 클래스를 생성합니다.

public class JwtProperties {
    public static final String SECRET = "minholee93";
    public static final int EXPIRATION_TIME = 864000000; // 10 days
    public static final String TOKEN_PREFIX = "Bearer ";
    public static final String HEADER_STRING = "Authorization";
}

• SECRET : JWT Token을 hash 할 때 사용할 secret key 입니다.

• EXPIRATION_TIME : JWT Token의 validation 기간입니다.

• TOKEN_PREFIX : JWT Token의 prefix는 Bearer 입니다.

• HEADER_STRING : JWT Token은 Authorization header로 전달됩니다.

2-2) LoginViewModel

다음으로 로그인 request dto를 생성합니다.

@Getter
@Setter
public class LoginViewModel {
    private String username;
    private String password;
}

2-3) JwtAuthenticationFilter

다음으로 Authentication logic을 수행할 Filter를 생성합니다.

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;


    /* Trigger when we issue POST request to /login
    We also need to pass in {"username":"minho", "password":"minho123"} in the request body
    * */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        // Grab credentials and map then to LoginViewModel
        LoginViewModel credentials = null;
        try {
            credentials = new ObjectMapper().readValue(request.getInputStream(), LoginViewModel.class);
        } catch (IOException e) {
            e.printStackTrace();
        }

        // Create login token
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                credentials.getUsername(),
                credentials.getPassword(),
                new ArrayList<>()
        );

        // Authenticate user
        Authentication auth = authenticationManager.authenticate(authenticationToken);
        return auth;
    }


    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        // Grab principal
        UserPrincipal principal = (UserPrincipal) authResult.getPrincipal();

        // Create JWT Token
        String token = JWT.create()
                .withSubject(principal.getUsername())
                .withExpiresAt(new Date(System.currentTimeMillis() + JwtProperties.EXPIRATION_TIME))
                .sign(Algorithm.HMAC512(JwtProperties.SECRET.getBytes()));

        // Add token in response
        response.addHeader(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX + token);
    }
}

attempAuthentication

attemptAutentication 메서드는 /login request 요청시 수행되는 메서드 입니다.

• request json body로 전달된 username/password를 LoginViewModel 클래스로 변환합니다.

• LoginViewModel의 필드에서 username/password를 가져와 UsernamePasswordAuthenticationToken을 생성합니다.

• UsernamePasswordAuthenticationToken은 사용자에게 전달하는 JWT Token이 아닌 Spring이 Authentication logic에 사용할 Token 입니다.

• AuthenticationManager에 위의 token을 전달해 Authentication 객체를 생성합니다.

• Authentication 객체를 사용해 Spring Security가 인증을 수행하고, 인증이 정상적으로 완료되면 Authentication 객체는 successfulAuthentication 메서드로 전달됩니다.

successfulAuthentication

successfulAuthentication 메서드는 로그인 성공시 수행되는 메서드 입니다.

• 전달된 Authentication 객체에서 UserPrincipal 객체를 가져옵니다.

• UserPrincipal의 필드값(username)을 Subject로 하는 JWT Token을 생성합니다.

• 생성한 JWT Token은 response의 header에 전달합니다.

3. Authorization

JWT Authorization은 다음과 같이 구현할 수 있습니다.

Authorization은 앞서 Authentication에서 획득한 JWT Token을 가지고 request를 요청할때 수행됩니다.

3-1) JwtAuthorizationFilter

Authorization을 수행하는 filter를 다음과 같이 생성합니다.

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    private UserRepository userRepository;

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserRepository userRepository) {
        super(authenticationManager);
        this.userRepository = userRepository;
    }


    // endpoint every request hit with authorization
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        // Read the Authorization header, where the JWT Token should be
        String header = request.getHeader(JwtProperties.HEADER_STRING);

        // If header does not contain BEARER or is null delegate to Spring impl and exit
        if(header == null || !header.startsWith(JwtProperties.TOKEN_PREFIX)){
            // rest of the spring pipeline
            chain.doFilter(request, response);
            return;
        }

        // If header is present, try grab user principal from database and perform authorization
        Authentication authentication = getUsernamePasswordAuthentication(request);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // Continue filter execution
        chain.doFilter(request, response);
    }

    private Authentication getUsernamePasswordAuthentication(HttpServletRequest request) {
        String token = request.getHeader(JwtProperties.HEADER_STRING);
        if(token != null){
            // parse the token and validate it (decode)
            String username = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET.getBytes()))
                    .build()
                    .verify(token.replace(JwtProperties.TOKEN_PREFIX, ""))
                    .getSubject();

            // Search in the DB if we find the user by token subject (username)
            // If so, then grab user details and create spring auth token using username, pass, authorities/roles
            if(username != null){
                User user = userRepository.findByUsername(username);
                UserPrincipal principal = new UserPrincipal(user);
                UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(username, null, principal.getAuthorities());
                return auth;
            }

            return null;
        }
        return null;
    }
}

doFilterInternal

doFilterInternal 메서드는 authorization 이 포함된 request에 대한 endpoint 입니다.

• request에서 Authorization Header를 획득합니다.

• Authorization Header가 null이 아니면, getUserPasswordAuthentication 메서드에 header를 전달해 Authentication 객체를 return 받습니다.

• 전달받은 Authentication 객체를 SecurityContextHolder에 저장합니다.

• Authorization이 정상적으로 수행되면, Spring의 나머지 FilterChain들을 수행할 수 있도록 doFilter(request, response)를 호출합니다.

getUsernamePasswordAuthentication

getUsernamePasswordAuthentication 메서드는 전달받은 Authorizatoin 헤더에서 사용자 정보를 획득해 UsernamePasswordAuthenticationToken 객체를 생성합니다.

• Authorization Header에서 JWT Token을 얻습니다.

• JWT Token을 decode해 subject에서 username을 획득합니다.

• username으로 DB를 조회해 User 객체를 생성합니다.

• User 객체를 전달해 UserPrincipal 객체를 생성합니다.

• username과 User의 authorities로 구성된 UserPasswordAuthenticationToken을 생성해 Authentication 객체로 return 합니다.

4. Configuration

마지막으로 JWT를 사용하기위해 다음과 같이 Security Configuration을 수정합니다.

다른 부분은 이전글과 동일하며 configure에서 http를 정의하는 부분만 변경하였습니다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    private final UserPrincipalDetailsService userPrincipalDetailsService;
    private final UserRepository userRepository;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(authenticationProvider());
    }

    @Bean
    DaoAuthenticationProvider authenticationProvider(){
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        daoAuthenticationProvider.setUserDetailsService(this.userPrincipalDetailsService);

        return daoAuthenticationProvider;
    }

    /**
     * JWT Authentication version
     * */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                // remove csrf and state in session because in jwt we do not need them
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                // add jwt filters (1. authentication, 2. authorization)
                .addFilter(new JwtAuthenticationFilter(authenticationManager()))
                .addFilter(new JwtAuthorizationFilter(authenticationManager(),  this.userRepository))
                .authorizeRequests()
                // configure access rules
                .antMatchers(HttpMethod.POST, "/login").permitAll()
                .antMatchers("/api/public/management/*").hasRole("MANAGER")
                .antMatchers("/api/public/admin/*").hasRole("ADMIN")
                .anyRequest().authenticated();
    }

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

• csrf와 session은 JWT 기반 Security에서는 사용하지 않으므로 disable 처리합니다.

• JwtAuthentication Filter와 JwtAuthorization Filter를 위와 같은 순서대로 선언합니다. 이때 반드시 Authentication이 앞에 와야합니다.

• /login은 모든 사용자들이 접근할 수 있도록 선언합니다.

• /api/public/management/* 는 MANAGER 권한을 가진 사용자만 접근 할 수 있도록 선언합니다.

• /api/public/admin/* 는 ADMIN 권한을 가진 사용자만 접근 할 수 있도록 선언합니다.

• 이외의 모든 request는 로그인한 사용자만 접근할 수 있도록 선언합니다.

5. Test

이제 테스트 해보도록 하겠습니다. 😎

저는 Insomnia를 사용해 API를 테스트하겠습니다.

5-1) login

/login으로 POST request를 날려보면

정상적으로 JWT Token이 return 되는 것을 확인할 수 있습니다.

5-2) authorization request

이후 획득한 JWT Token을 Authorization header에 담아 authorization request를 요청하면

정상적으로 결과값을 return 하는 것을 확인할 수 있습니다.

참.. 어렵네요 😅😅😅. 다음글에서는 JWT를 사용한 Google Login에 대해 다뤄보도록 하겠습니다.


참고 자료 : https://www.youtube.com/playlist?list=PLVApX3evDwJ1d0lKKHssPQvzv2Ao3e__Q


추천서적

 

스프링5 레시피:스프링 애플리케이션 개발에 유용한 161가지 문제 해결 기법

COUPANG

www.coupang.com

파트너스 활동을 통해 일정액의 수수료를 제공받을 수 있음


반응형

댓글

Designed by JB FACTORY