Series Spring Security + JWT cho REST API (Phần 01)

  • Phuong Dang
  • 05/Mar/2022
  1. Authentication flow
  2. Installation
  3. Validate user & generate token
  4. JWT Filter & configure api
  5. Testing
  6. Conclusion
Trong series này chúng ta sẽ cùng nhau sử dụng spring security kết hợp JWT để xây dựng tính năng authentication và authorization cho một Java web application

1. Authentication flow

JWT token là một chuỗi ký tự encode và được đính kèm với một SIGNATURE để đảm bảo tính toàn vẹn của token.
Nhờ đó mà bất kỳ sửa đổi nào cũng sẽ khiến JWT token trở thành không hợp lệ.

Thông thường một ứng dụng sử dụng JWT (Json web Token) để authentication sẽ có flow như bên sau:

Spring security overview
Figure 01. Architecture Overview.

2. Installation

Chúng ta sẽ sử dụng Spring boot để tạo và config một web application có apply spring security. Các dependency cần thiết trong file pom.xml như sau:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.1</version>
    </dependency>
    <!-- ... dependency working with database ... -->
   <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
   <!-- ... OTHER DEPENDENCIES: lombok ... -->
</dependencies>

Khi chúng ta add spring-security vào application thì Spring boot sẽ tự động đăng ký filters chain để chặn và kiểm tra tính hợp lệ của tất các các request từ phía client gửi lên. Mỗi filter trong filters chain sẽ có một nhiệm vụ khác nhau, ví dụ:

  • UsernamePasswordAuthenticationFilter: Thực hiện validate thông tin username và password
  • CorsFilter: Ngăn chặn việc truy cập Cross origin request từ các domain khác với server hoặc không được cho phép
  • ....

Nhiệm vụ của chúng ta sẽ là tạo ra 1 api login để generate JWT token (Step 1 + Step 2) và JWTFilter để xác thực token (Step 3) trong authentication flow

3. Validate user & generate token

Spring security overview
Figure 02. Validate user & generate token flow

LoginController.java

/**
 * @author <a href="mailto:phuongdp.tech@gmail.com">PhuongDP</a>
*/
@RestController
@RequestMapping("/api/login")
@AllArgsConstructor
public class LoginController {
    private final TokenProvider tokenProvider;

    private final AuthenticationManagerBuilder authenticationManagerBuilder;

    @PostMapping()
    public ResponseEntity<JWTToken> authorize(@Valid @RequestBody LoginRequestDto requestDto) {
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                requestDto.getAccount().toLowerCase(),
                requestDto.getPassword()
        );

        // This line will call the implementation of UserDetailsService
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // If have no exception is thrown, it's mean account valid
        String jwt = tokenProvider.createToken(authentication);
        return ResponseEntity.ok(JWTToken.builder().accessToken(jwt).build());
    }
}
Class này có nhiệm vụ expose 1 API POST /login nhận tham số là account & password. Sau đó call AuthenticationService để xác thực và response về JWT token khi account hợp lệ.

AuthenticationService.java

/**
 * @author <a href="mailto:phuongdp.tech@gmail.com">PhuongDP</a>
*/

@AllArgsConstructor
@Service
public class AuthenticationService implements UserDetailsService {
    private final AccountRepository accountRepository;

    @Override
    @Transactional
    public UserDetails loadUserByUsername(final String account) {
        return accountRepository
                .findByAccount(account)
                .map(this::createSpringSecurityUser)
                .orElseThrow(() -> new UsernameNotFoundException("Account " + account + " was not found in the database"));
    }

    private org.springframework.security.core.userdetails.User createSpringSecurityUser(Account account) {
        List<GrantedAuthority> grantedAuthorities =
                Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + account.getAuthority().name()));
        return new org.springframework.security.core.userdetails.User(account.getAccount(), account.getPassword(), grantedAuthorities);
    }
}
UserDetailsService là một interface định nghĩa duy nhất một method loadUserByUsername() với ý nghĩa tạo ra một cơ chế mở "làm thể nào để xác thực user tồn tại hay không?". Chúng ta sẽ cần phải implement và cài đặt chi tiết cách thức xác thực.
Trường hợp account input là hợp lệ, chúng ta sẽ tạo ra 1 "internal" user theo format quy định của spring-security, ngược lại sẽ throw exception.

TokenProvider.java

/**
 * @author <a href="mailto:phuongdp.tech@gmail.com">PhuongDP</a>
*/

@Component
public class TokenProvider {

    private String secretKey;
    private final String AUTHORITIES_KEY = "authorities";

     public String createToken(Authentication authentication) {
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority).collect(Collectors.joining(","));

        LocalDateTime exp = LocalDateTime.now().plusHours(1);

        return Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .setExpiration(DateTimeUtils.convertToUtilDate(exp))
                .signWith(SignatureAlgorithm.HS512, secretKey)
                .compact();
    }

    public Authentication getAuthentication(String token) {
        if (!StringUtils.hasText(token)) {
            return null;
        }
        try {
           Claims claims = Jwts.parser()
                                .setSigningKey(secretKey)
                                .parseClaimsJws(token)
                                .getBody();

           Collection<? extends GrantedAuthority> authorities = Arrays
                                .stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                                .filter(auth -> !auth.trim().isEmpty())
                                .map(SimpleGrantedAuthority::new)
                                .collect(Collectors.toList());

           User principal = new User(claims.getSubject(), "", authorities);

           return new UsernamePasswordAuthenticationToken(principal, token, authorities);
        } catch (Exception e) {
            return null;
        }

    }
}
TokenProvider cung cấp 2 methods:
  • createToken: mục đích generate JWT token
  • getAuthentication: parse và lấy thông tin từ JWT token

4. JWT Filter & configure api

Spring security overview
Figure 03. Validate JWT token
Như đề cập bên trên chúng ta sẽ có filters chain của spring security được auto configuration. Những filters này có nhiệm vụ bảo vệ các API trước tất cả các request từ phía client. Nếu trong SecurityContext có thông tin Authentication thì request được xác định là authorized, ngược lại thì sẽ là 1 unauthorized request.
  • Authentication: Là thông tin (username/authorities...) của currently authentucated user.
  • SecurityContext: Nơi lưu thông tin của currently authenticated user.
  • SecurityContextHolder: Là một helper class giúp ta access vào SecurityContext.

Nhiệm vụ của chúng sẽ sẽ cần tạo ra JWTFilter để xác thực token và đảm bảo rằng JWTFilter cần chạy trước filter chain của spring-security. Nếu token hợp lệ thì một UsernamePasswordAuthenticationToken sẽ được khởi tạo và đưa vào SecurityContext, ngược lại thì 1 HTTP Status 401 sẽ được response

JWTFilter.java

/**
 * @author <a href="mailto:phuongdp.tech@gmail.com">PhuongDP</a>
*/

public class JWTFilter extends GenericFilterBean {

    public static final String AUTHORIZATION_HEADER = "Authorization";

    private final TokenProvider tokenProvider;

    public JWTFilter(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
        throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String jwt = resolveToken(httpServletRequest);
        Authentication authentication = this.tokenProvider.getAuthentication(jwt);
        if (Objects.nonNull(authentication)) {
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        filterChain.doFilter(servletRequest, servletResponse);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

JWTConfigurer.java

/**
 * @author <a href="mailto:phuongdp.tech@gmail.com">PhuongDP</a>
*/
@AllArgsConstructor
public class JWTConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private final TokenProvider tokenProvider;

    @Override
    public void configure(HttpSecurity http) {
        JWTFilter customFilter = new JWTFilter(tokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}
JWTConfigurer có mục đích config đảm bảo JWTFilter sẽ chạy trước UsernamePasswordAuthenticationFilter (Một trong những filter có chức năng kiểm tra thông request đã authenticated hay chưa thông qua username/password)

SecurityConfiguration.java

/**
 * @author <a href="mailto:phuongdp.tech@gmail.com">PhuongDP</a>
*/
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
@AllArgsConstructor
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    private final CorsFilter corsFilter;
    private final TokenProvider tokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

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

    @Override
    public void configure(HttpSecurity http) throws Exception {
        // @formatter:off
        http
            .csrf()
            .disable()
            .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling()
            .authenticationEntryPoint(jwtAuthenticationEntryPoint)
        .and()
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        .and()
            .authorizeRequests()
            .antMatchers("/api/login").permitAll()
            .anyRequest().authenticated()
        .and()
            .httpBasic()
        .and()
            .apply(securityConfigurerAdapter());
        // @formatter:on
    }

    private JWTConfigurer securityConfigurerAdapter() {
        return new JWTConfigurer(tokenProvider);
    }
}
Trong SecurityConfiguration chúng ta có một số config quan trọng như sau:
  • Thuật toán mã hoã password: @Bean PasswordEncoder
  • Unprotected api với url sau: /api/login
  • Apply JWTConfigurerJwtAuthenticationEntryPoint
  • Disable csrf
  • Apply cors filter

5. Testing

Trong source demo của series này chúng ta sẽ sử dụng database với Mysql và Postman để call API.

Chúng ta sẽ cần 1 account demo để thực hiện test. Khi start app tôi đã sử dụng CommandLineRunner để khởi tạo 1 account admin/123456

Start application

Chúng ta sẽ thử call 1 protected API mà không có JWT token đi kèm. Kết quả mong đợi thì request này sẽ nhận về HTTP 401 Unauthorized

Call protected API without JWT token

Call API login với thông tin account admin/123456 để nhận về JWT token

Call API login to get JWT token

Sử dụng JWT token vừa nhận được add vào request header và call protected API

Call protected API with JWT token

6. Conclusion

Với spring-security chúng ta có nhiều cách thức triển khai: OAuth2, SAML2... Trên đây là 1 cách thức đơn giản triển khai cùng với JWT Token.
Hi vọng rằng sau bài viết này các bạn có thể hiểu và áp dụng spring-security được với app của mình

Source code tham khảo