+5

[Spring security] - Spring Boot Security JWT Architecture

Bài viết này mình lại tiếp tục về chủ đề bảo mật nằm trong loạt bài viết về Spring Security JWT.

Mọi người có thể tìm đọc các bài viết liên quan tại đây!

Spring Boot Security JWT Architecture

Chúng ta có thể thấy quy trình xác thực của Spring security: đầu tiên là nhận HTTP request, filter, xác thực, lưu trữ dữ liệu đối tượng xác thực (Authentication), tạo mã xác thực JWT (generate token), get User details, authorize, handle exception,...

Giải thích về các class trong hình vẽ trên:

  • SecurityContextHolder cung cấp quyền truy cập vào SecurityContext.
  • SecurityContext giữ thông tin xác thực Authentication.
  • Authentication đại diện cho đối tượng xác thực Principal (bao gồm thông tin GrantedAuthority - phản ánh quyền hạn truy cập ứng dụng của Principal ).
  • UserDetails chứa các thông tin cần thiết để tạo đối tượng Authentication
  • UserDetailsService là interface tạo ra UserDetails từ thông tin username đăng nhập. (UserDetailsService thường được sử dụng bởi AuthenticationProvider. UserDetailsService giao tiếp với cơ sở dữ liệu MySQL thông qua Spring Data JPA).
  • JwtAuthTokenFilter kế thừa OncePerRequestFilter sẽ tiền xử lý HTTP request, từ thông tin Token tạo ra đối tượng Authentication và lưu nó vào SecurityContext.
  • JwtProvider validates, parser Token và generate Token từ thông tin UserDetails.
  • Từ thông tin username và password trong request đăng nhập tạo ra instance của UsernamePasswordAuthenticationToken là implement của interface Authentication (để biết điều này phải đọc sâu vào trong code mới thấy được). Rồi AuthenticationManager sẽ sử dụng instance này để xác thực tài khoản đăng nhập.
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
...
}
và
public abstract class AbstractAuthenticationToken implements Authentication, CredentialsContainer {
...
}
  • AuthenticationManager sử dụng DaoAuthenticationProvider cùng với UserDetailsService & PasswordEncoder validate instance của UsernamePasswordAuthenticationToken. Nếu thành công, AuthenticationManager trả về một đối tượng Authentication đầy đủ thông tin (bao gồm cả các quyền).
  • OncePerRequestFilter là Filter thực thi một lần duy nhất cho mỗi Request tới API. Nó cung cấp một phương thức doFilterInternal() - trong phương thức này ta sẽ triển khai: parse và validate chuỗi JWT, lấy thông tin người dùng (sử dụng UserDetailsService), kiểm tra Authorization.
  • AuthenticationEntryPoint là class sẽ handle các lỗi xác thực AuthenticationException.
  • Resful API sẽ được bảo vệ bởi Method Security Expressions (thông qua quyền hạn cho phép).

Nhận HTTP Request

Request có thể đến từ Browser, web service client, Ajax,... - Spring không quan tâm. Nhưng Request này sẽ phải đi qua một chuỗi các bộ lọc (Filter chain) cho các mục đích xác thực và ủy quyền.

Chuỗi bộ lọc đó sẽ được áp dụng cho đến khi tìm thấy bộ lọc xác thực có tham gia trong cấu hình của chúng ta (trong bài viết là class AuthenticationFilter kế thừa class OncePerRequestFilter ).

Filter Request

Trong bài viết Spring Boot Security - JWT Authentication mình sử dụng AuthenticationFilter kế thừa class OncePerRequestFilter (abtract class) vào chuỗi các bộ lọc.

class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        ...
        http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
    }
}

Class AuthenticationFilter sẽ validate Access Token trong Header của Request trước khi Request đến Resource (các api):

public class AuthenticationFilter extends OncePerRequestFilter {
    @Autowired
    private JwtUtils jwtUtils;

    @Autowired
    private UserDetailsServiceImpl userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        try {
            String token = getTokenFromRequest(request);
            if (token != null && jwtUtils.validateJwtToken(token)) {
                String username = jwtUtils.getUserNameFromJwtToken(token);
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception e) {
            logger.error("Cannot set user authentication: {}", e);
        }

        filterChain.doFilter(request, response);
    }
    ...
}

Có 2 trường hợp xảy ra.

  • Signin/Signup: là 2 api không được bảo vệ quyền truy cập.
  • Các api được bảo vệ quyền truy cập: Nếu Access Token null/invalid thì lỗi AuthenticationException xảy ra sẽ được AuthenticationEntryPoint xử lý. Nếu Access Token hợp lệ thì sẽ tạo ra Authentication được dùng ở phía sau đó.

Tạo Authentication từ Access Token

AuthenticationFilter lấy được thông tin username/password từ Access Token trong Header của Request. Tiếp tục thực hiện:

  • Tạo ra instance của UsernamePasswordAuthenticationToken (là implements của Authentication, đã giải thích bên trên)
  • Sử dụng instance của UsernamePasswordAuthenticationToken như đối tượng Authentication và lưu vào trong SecurityContext để các bộ lọc trong tương lai sử dụng (ví dụ: Authentication filters)
    ...
    String token = getTokenFromRequest(request);
    if (token != null && jwtUtils.validateJwtToken(token)) {
        String username = jwtUtils.getUserNameFromJwtToken(token);
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                userDetails, null, userDetails.getAuthorities());
        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
    ...

Lưu đối tượng Authentication vào SecurityContext

SecurityContextHolder.getContext().setAuthentication(authentication);

SecurityContextHolder là đối tượng cơ bản nhất lưu trữ thông tin chi tiết về security context hiện tại của ứng dụng. Spring Security sử dụng đối tượng Authentication để đại diện cho thông tin này và ta có thể truy vấn đối tượng Authentication này từ bất kỳ đâu trong ứng dụng:

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// thông tin user đã được xác thực
Object principal = authentication.getPrincipal();
// hoặc cast đối tượng về đối tượng do ta định nghĩa như bên dưới (UserDetailsImpl implements UserDetails)
UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();

Ủy quyền đối tượng Authentication cho AuthenticationManagager

Sau khi đối tượng Authentication được tạo chúng ta sẽ truyền nó như một input parameter vào phương thức authenticate() của AuthenticationManager:

public interface AuthenticationManager {
    Authentication authenticate(Authentication authentication)
            throws AuthenticationException;
}

AuthenticationManager là một interface. Trong Spring Security thì implementation mặc định của nó là ProviderManager:

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
   private List providers;
   ...
}

Authenticate với AuthenticationProvider

ProviderManager ủy quyền cho một danh sách các AuthenticationProvider, mỗi provider này sẽ cố gắng xác thực người dùng, sau đó sẽ trả về một đối tượng Authentication hoặc sẽ throw ra một exception:

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
    ...

    public ProviderManager(List<AuthenticationProvider> providers) {
        this(providers, (AuthenticationManager)null);
    }
    ...

    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        ...
        while(var8.hasNext()) {
            AuthenticationProvider provider = (AuthenticationProvider)var8.next();
            ...
                try {
                    result = provider.authenticate(authentication);
                    if (result != null) {
                        this.copyDetails(authentication, result);
                        break;
                    }
                } 
                ...
            }
        }
        ...
}    

Dưới đây là một vài authentication providers Spring ung cấp (rất nhiều 😦):

  • DaoAuthenticationProvider
  • PreAuthenticatedAuthenticationProvider
  • LdapAuthenticationProvider
  • ActiveDirectoryLdapAuthenticationProvider
  • JaasAuthenticationProvider
  • CasAuthenticationProvider
  • RememberMeAuthenticationProvider
  • AnonymousAuthenticationProvider
  • RunAsImplAuthenticationProvider
  • OpenIDAuthenticationProvider

DaoAuthenticationProvider

DaoAuthenticationProvider hoạt động tốt với thông tin từ form đăng nhập hoặc xác thực HTTP Basic Authentication mà yêu cầu xác thực là tên username/password đơn giản. Nó xác thực người dùng bằng cách so sánh mật khẩu được gửi trong UsernamePasswordAuthenticationToken với mật khẩu được lấy từ UserDetailsService (as a DAO).

Cấu hình sử dụng UserDetailsService với AuthenticationManagerBuilder:

class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    UserDetailsServiceImpl userDetailsService;

    @Override
    public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
        authenticationManagerBuilder
                .userDetailsService(userDetailsService)
                .passwordEncoder(passwordEncoder());
    }

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

Sử dụng UsernamePasswordAuthenticationToken:

@Autowired
AuthenticationManager authenticationManager;
...
Authentication authentication = 
				authenticationManager.authenticate(
				    new UsernamePasswordAuthenticationToken(loginRequest.username, loginRequest.password)
				);

Lấy thông tin chi tiết người dùng với UserDetailsService

Chúng ta có thể lấy được thông tin người dùng trong đối tượng Authentication (authentication.getPrincipal()). Sau đó ép kiểu về đối tượng UserDetails để lấy được thông tin username, password, GrantedAuthoritys:

UserDetails userDetails = (UserDetails) authentication.getPrincipal();
// userDetails.getUsername()
// userDetails.getPassword()
// userDetails.getAuthorities()

Để sử dụng nhiều thông tin hơn ta tạo ra một đối tượng custom User imlements UserDetails và một customer service implements UserDetailsService và override lại phương thức loadUserByUsername()

@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    UserRepository userRepository;

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

    	User user = userRepository.findByUsername(username).orElseThrow(
    			() -> new UsernameNotFoundException("User Not Found with -> username or email : " + username));

    	return UserPrinciple.build(user); // UserPrinciple implements UserDetails
    }
}

Trong ví dụ này, chúng ta sử dụng UserRepository (implementation của Spring Data JPARepository) sau đó build đối tượng custom user (hay chính là UserDetails)

Lấy thông tin quyền hạn của người dùng

Authentication cung cấp phương thức getAuthorities() trả về danh sách đối tượng GrantedAuthority là quyền hạn của người dùng (ROLE_ADMIN, ROLE_PM, ROLE_USER...):

public interface Authentication extends Principal, Serializable {
    Collection getAuthorities();
    ...
}

Cuối cùng là sử dụng HTTPSecurity và Method Security để bảo vệ Resource

WebSecurityConfigurerAdapter là class chốt chặn triển khai bảo mật. Chúng ta cung cấp các cấu hình cho phương thức configure(HttpSecurity http) để cấu hình resource nào cần bảo vệ, exception handler nào được lựa chọn, filter nào được sử dụng và sử dụng khi nào,....:

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable().
                authorizeRequests()
                .antMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
                .and()
                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
                ...;
        
        http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
    }
}

Method Security Expressions

Spring cung cấp 1 số annotations để kiểm tra ủy quyền trước và sau khi check, filter các đối số đã gửi hoặc giá trị trả về: @PreAuthorize, @PreFilter, @PostAuthorize@PostFilter.

Để sử dụng các annotations này chúng ta cần thêm annotations @EnableGlobalMethodSecurity:

@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    ...
}

Ví dụ sử dụng:

@RestController
public class TestRestAPIs {
	
    @GetMapping("/api/test/user")
    @PreAuthorize("hasRole('USER') or hasRole('ADMIN')")
    public String userAccess() {
    	return ">>> User Contents!";
    }

    @GetMapping("/api/test/pm")
    @PreAuthorize("hasRole('PM') or hasRole('ADMIN')")
    public String projectManagementAccess() {
    	return ">>> Project Management Board";
    }
	
    @GetMapping("/api/test/admin")
    @PreAuthorize("hasRole('ADMIN')")
    public String adminAccess() {
        return ">>> Admin Contents";
    }
}

Handle AuthenticationException

Nếu HTTP Request tới một resource được bảo vệ mà không được xác thực, AuthenticationEntryPoint sẽ được gọi. Tại thời điểm này, một AuthenticationException throw ra, phương thức commence() sẽ được kích hoạt (trigger):

@Component
public class JwtAuthEntryPoint implements AuthenticationEntryPoint {
   
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException e) 
                            throws IOException, ServletException {
    	
        logger.error("Unauthorized error. Message - {}", e.getMessage());
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Error -> Unauthorized");
    }
}

Tổng kết

Trên đây là hướng dẫn để mọi người hiểu hơn về kiến trúc và cách cấu hình Spring Security Server để xác thực, phân quyền người dùng. Hy vọng bạn sẽ hiểu được ý tưởng tổng thể của bài viết và áp dụng nó vào dự án của bạn một cách thoải mái.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí