+2

[Phần 4] Spring Security 6 with JWT, Oauth2

1. Giới thiệu

Tiếp nối phần 3 trong series, phần này mình sẽ triển khai code authentication Security vào project. Mọi người có thể đọc lại phần 3 ở đây: https://viblo.asia/p/phan-3-spring-security-6-with-jwt-oauth2-obA46da0LKv

2. Triển khai code

Sau khi đã thực hiện xong phần register, chúng ta cần nhớ lại về Spring Security Internal Flow ở phần thứ 2.

2.1. Update User entity

Nếu mọi người còn nhớ ở trong bài viết trước, method loadUserByUsername ở trong interface UserDetailsService trả về dữ liệu có kiểu là interface UserDetails, vì vậy để làm việc được với Spring Security chúng ta cần phải implement kiểu dữ liệu này vào trong class User của chúng ta. Việc implement này giúp chúng ta vừa có thể làm việc với Spring Security vừa có thể define các thông tin mà chúng ta mong muốn để sử dụng cho các mục đích khác.

Trước khi implement, nếu mọi người trỏ vào trong interface UserDetails sẽ thấy có một method tên là getAuthorities(), method này sẽ get các quyền của User để phục vụ cho mục đích phân quyền, tuy nhiên để làm cho project này đơn giản nên thay vì tạo entity Role, thì mình sẽ define enum Role(nằm trong package entity) gồm 2 quyền ADMINUSER thôi:

public enum Role {
    USER,
    ADMIN
}

Bây giờ mình sẽ update lại User entity:

@Entity
@Table(name = "users")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long userId;
    private String username;
    private String password;
    private String firstName;
    private String lastName;
    private String email;
    @Enumerated(EnumType.STRING)
    private Role role;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(role.name()));
    }

    @Override
    public boolean isAccountNonExpired() {
        return UserDetails.super.isAccountNonExpired();
    }

    @Override
    public boolean isAccountNonLocked() {
        return UserDetails.super.isAccountNonLocked();
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return UserDetails.super.isCredentialsNonExpired();
    }

    @Override
    public boolean isEnabled() {
        return UserDetails.super.isEnabled();
    }
}

2.2. ApplicationConfiguration

Tiếp theo để có thể sử dụng được các method mà chúng ta đã đề cập đến ở phần 2, có những method nằm ở bên trong các interface, chúng ta cần phải tạo ra các instance của các interface đó với annotation @Bean để có thể sử dụng chúng. Vì vậy ở trong package config, chúng ta sẽ có class ApplicationConfiguration:

@Configuration
@RequiredArgsConstructor
public class ApplicationConfiguration {
    private final UserRepository userRepository;

    @Bean
    public UserDetailsService userDetailsService() {
        return username -> userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found"));
    }
    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
            authProvider.setUserDetailsService(userDetailsService());
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
            throws Exception {
        return config.getAuthenticationManager();
    }

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

Ngoài ra cũng cần phải tạo thêm method findByUsername bên trong UserRepository để phục vụ mục đích tìm kiếm user của UserDetailsService:

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
}

2.3. AuthenticationFilter

Ở đây chúng ta sẽ cần tạo class AuthenticationFilter phục vụ mục đích filter các request mà client gửi đến như đã đề cập ở bước 1 phần 2. Trong phần trước khi kiểm tra ở trong class AuthenticationFilter của Spring Security, nó có extends một abstract class có tên là OncePerRequestFilter

Abstract class OncePerRequestFilter giúp đảm bảo rằng phương thức doFilterInternal() chỉ được gọi một lần cho mỗi request tránh việc thực thi trùng lặp. Ví dụ như việc xác thực người dùng nhiều lần có thể dẫn đến truy vấn cơ sở dữ liệu không cần thiết hoặc tạo ra nhiều đối tượng xác thực.

OncePerRequestFilter sử dụng một flag để theo dõi xem phương thức doFilterInternal() đã được gọi hay chưa cho yêu cầu hiện tại, khi có request gọi đến, nó sẽ kiểm tra flag này, nếu cờ này chưa được đặt thì nó sẽ đi vào phương thức doFilterInternal(), nếu đặt rồi thì nó sẽ bỏ qua việc lọc. Ví dụ như khi client gửi một jwt token đến, chúng ta chỉ cần thực hiện việc xác thực một lần thôi là đủ, khi cho việc xác thực vào bên trong method doFilterInternal(), nó sẽ hoạt động đúng như yêu cầu chúng ta đưa ra ở trên.

@Component
@RequiredArgsConstructor
public class AuthenticationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {
        final String authHeader = request.getHeader("Authorization");
        final String jwt;
        final String userEmail;
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        // xác thực user với jwt ở đây
        
        filterChain.doFilter(request, response);
    }
}

Tạm thời mình sẽ chỉ viết phần khung của method này mà chưa viết phần xác thực jwt. Như mọi người cũng biết thì khi một request từ client gọi đến, nếu có phần xác thực thì sẽ có thêm 1 key là Authorization như dưới đây: image.png Tiếp đến theo quy ước thì phần bắt đầu của value của Authorization sẽ là Bearer {token}. Chính vì vậy, trước khi xác thực thông tin user, chúng ta cần phải kiểm tra xem phần authHeader có tồn tại và bắt đầu với Bearer hay không. Nếu tồn tại thì mới cho phép xác thực.

2.4. JwtService và JwtServiceImpl

Ở bài viết trước chúng ta đã có method register() rồi, ở đây để phục vụ mục đích xác thực, chúng ta sẽ thêm method extractUsername():

public interface JwtService {
    String extractUsername(String token);
    String generateToken(User user);
}

Tiếp theo sẽ là class JwtServiceImpl được implement từ JwtService:

@Service
public class JwtServiceImpl implements JwtService {
    @Override
    public String extractUsername(String token) {
        return "";
    }
}

Vì sao chúng ta lại cần method extractUsername(String token)? Như mình đã nói ở trên, chúng ta cần phải có dữ liệu đầu vào cho method loadUserByUsername(String username). Để lấy được thì tất nhiên chúng ta cần phải trích xuất nó từ token mà client gửi cho server rồi. Vậy làm thế nào để lấy được username từ token? Nếu mọi người xem lại phần 3 cũng ở trong JwtServiceImpl có method generateToken như sau:

 @Override
public String generateToken(User user) {
    return Jwts
            .builder()
            .setSubject(user.getUsername())
            .setIssuedAt(new Date(System.currentTimeMillis()))
            .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 24))
            .signWith(getSignInKey(), SignatureAlgorithm.HS256)
            .compact();
}

Chúng ta đã set Subject của token có value là username. Nếu mọi người đã tìm hiểu về JWT rồi thì mọi người sẽ biết đến Claims.

Claim được chứa trong phần Payload của JWT, gồm các cặp key-value dùng để lưu trữ thông tin của người dùng và các thuộc tính khác được mã hóa bằng chữ ký kỹ thuật số để đảm bảo tính toàn vẹn và tính xác thực của dữ liệu.

Để có thể lấy được thông tin username trong token, thì chúng ta cần phải lấy được Claims ở bên trong phần Payload của token, vì nó là phần chứa thông tin của người dùng. Và chữ ký kỹ thuật số mà mình đề cập ở trong định nghĩa Claims chính là method getSignInKey() mà chúng ta đã tạo ở phần trước.

private Claims extractAllClaims(String token) {
    return Jwts.parserBuilder()
                .setSigningKey(getSignInKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
}

Vậy là chúng ta đã có Claims rồi. Tuy nhiên có một vấn đề chúng ta cần phải lưu ý đó là thời gian hết hạn của Token. Ở method generateToken, chúng ta có define như sau: .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 24)). Cho nên trước khi lấy thông tin của user chúng ta cần phải xác nhận Token vẫn còn valid, method extractUsername sẽ được update như sau:

@Override
public String extractUsername(String token) {
    Claims claims = extractClaims(token);
    if (claims != null) {
        Date expirationTime = claims.getExpiration();
        boolean isExpired = expirationTime.before(Date.from(Instant.now()));
        if (!isExpired) {
            return claims.getSubject();
        } else return null;
    }
    return null;
}

2.5. Cập nhật AuthenticationFilter

Sau khi đã có username rồi, chúng ta có thể gọi đến method this.userDetailsService.loadUserByUsername(username) để lấy thông tin của user:

@Override
protected void doFilterInternal(
        @NonNull HttpServletRequest request,
        @NonNull HttpServletResponse response,
        @NonNull FilterChain filterChain
) throws ServletException, IOException {
    final String authHeader = request.getHeader("Authorization");
    final String token;
    final String username;
    if (authHeader == null || !authHeader.startsWith("Bearer ")) {
        filterChain.doFilter(request, response);
        return;
    }

    // xác thực user với jwt ở đây
    token = authHeader.substring(7);
    username = jwtService.extractUsername(token);
    if(username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
        UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
    }

    filterChain.doFilter(request, response);
}

Sau khi đã xác thực user với token xong rồi, chúng ta cần lưu thông tin và quyền hạn của user vào trong SecurityContextHolder để sau đó có thể sử dụng trong quá trình xác thực và kiểm tra quyền truy cập trong ứng dụng Spring Security. Bây giờ chúng ta lại trỏ vào trong class SecurityContextHolder, ở đây có 1 method tên là getContext() được dùng để lấy ra context hiện tại của Spring Security

Context chứa các chi tiết liên quan đến người dùng đã được xác thực, bao gồm thông tin xác thực (credentials), thông tin người dùng (principal), và các quyền hạn (authorities)

Ok, chúng ta tiếp tục với method getContext(), nó trả về data có kiểu dữ liệu là SecurityContext, bên trong có chứa một method tên là setAuthentication(Authentication authentication) được dùng để cập nhật thông tin user hiện tại. Để có thể sử dụng được method này, chúng ta sẽ sử dụng một class có tên là UsernamePasswordAuthenticationToken được implement từ interface Authentication ở trên. Cho nên chúng ta sẽ tiếp tục việc lưu trữ thông tin User vào trong SecurityContextHolder như sau:

if(username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
    UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
    UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
            userDetails, null, userDetails.getAuthorities()
    );
    authToken.setDetails(
            new WebAuthenticationDetailsSource().buildDetails(request)
    );
    SecurityContextHolder.getContext().setAuthentication(authToken);
}

2.6. Cập nhật SecurityConfiguration

Như vậy chúng ta đã hoàn thành phần AuthenticationFilter, để ứng dụng có thể sử dụng nó vào trong việc xác thực, chúng ta cần cập nhật lại SecurityConfiguration

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
    private final AuthenticationFilter authenticationFilter;
    private final AuthenticationProvider authenticationProvider;

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .cors(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests((authorize) -> authorize
                        .requestMatchers("/api/auth/**").permitAll()
                        .anyRequest()
                        .authenticated())
                .sessionManagement(session -> 
                        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authenticationProvider(authenticationProvider)
                .addFilterBefore(authenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }
}

Nhắc lại một chút, ở phần 2.2. ApplicationConfig mình có config một số thông tin liên quan đến PasswordEncoderUserDetailsService.

Bởi vì PasswordEncoder chỉ là 1 interface chứa các method chứ không chứa logic xử lý, nên chúng ta cần phải tạo ra một Bean trả về class implementation từ PasswordEncoder, ở đây là BCryptEncoder thì các method gọi đến PasswordEncoder mới biết cách để xử lý logic. UserDetailsService cũng tương tự vậy.

Nên chúng ta cũng cần config lại AuthenticationProvider sử dụng PasswordEncoderUserDetailsService ở trên.

Và tất nhiên Spring Security cũng chẳng thể tự động hiểu là cần phải sử dụng AuthenticationProvider nào, nên ở đây chúng ta cần phải inject Bean chúng ta đã tạo với method .authenticationProvider(authenticationProvider). Còn addFilterBefore thì đúng như tên gọi của nó, khi request từ client gửi đến backend trước khi thực thi bất kỳ logic gì, nó sẽ chạy vào authenticationFilter trước.

2.7. AuthenticationService và AuthenticationServiceImpl

Như vậy là chúng ta đã hoàn thành phần config cho project, tuy nhiên lại có một vấn đề khác chúng ta cần xử lý. Khi tạo user, chúng ta lưu mật khẩu dưới dạng plain text: newUser.setPassword(request.getPassword());. Việc này vi phạm 2 nguyên tắc:

  • Mật khẩu sẽ bị lộ nếu người khác truy cập vào database của chúng ta
  • Không thể xác thực vì authentication sử dụng PasswordEncoder

Chính vì vậy trước khi update logic của phần login, chúng ta cần chỉnh sửa một chút việc lưu mật khẩu cho method register ở class AuthenticationServiceImpl:

public class AuthenticationServiceImpl implements AuthenticationService {
        private final PasswordEncoder passwordEncoder;
        
        @Override
        public AuthenticationResponse register(RegisterRequest request) {
        // other logic...
        newUser.setPassword(passwordEncoder.encode(request.getPassword()));  
        // other logic...
        }

Tiếp theo chúng ta sẽ viết các logic liên quan đến đăng nhập. Đầu tiên cần tạo method ở bên trong AuthenticationService:

public interface AuthenticationService {
    AuthenticationResponse register(RegisterRequest request);
    AuthenticationResponse login(AuthenticationRequest request); // login method
}

Và cập nhật AuthenticationServiceImpl:

private final AuthenticationManager authenticationManager; // declare autowired
// register logic ...

// login logic
public AuthenticationResponse login(AuthenticationRequest request) {
    authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                    request.getUsername(),
                    request.getPassword()
            )
    );
    User user = userRepository.findByUsername(request.getUsername())
            .orElseThrow();
    var jwtToken = jwtService.generateToken(user);
    Token token = Token.builder()
            .userId(user.getUserId())
            .token(jwtToken)
            .expired(false)
            .revoked(false)
            .build();
    tokenRepository.save(token);
    UserDto userDto = UserMapper.mapToUserDto(user);
    return AuthenticationResponse.builder()
            .userDto(userDto)
            .token(jwtToken)
            .build();
}

Phần logic này thì khá quen thuộc với mọi người rồi, mình sẽ chỉ nói về AuthenticationManager thôi. Mọi người hãy quay lại phần 2 ở bước thứ 3: AuthenticationManager một chút. Đoạn code mình viết ở trên chính là phần xác thực cho bước này, còn các bước sau thực hiện ở background của Spring Boot thì đã được chúng ta config từ đầu. Vì vậy nên ở đây nhìn thì có thể thấy đơn giản nhưng thực tế method authenticationManager.authenticate nó sẽ gọi đến toàn bộ các logic bên dưới của Spring Security mà chúng ta đã phân tích ở phần 2. Sau khi đã authenticate xong rồi thì chúng ta thực hiện các logic như bên dưới thôi.

2.8. Cập nhật AuthenticationController

@RequestMapping("/api/auth")
@RestController
@RequiredArgsConstructor
public class AuthenticationController {
    private final AuthenticationServiceImpl authenticationService;

    @PostMapping("/register")
    public ResponseEntity<AuthenticationResponse> register(
            @RequestBody RegisterRequest registerRequest
    ) {
        AuthenticationResponse response = authenticationService.register(registerRequest);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    @PostMapping("/login")
    public ResponseEntity<AuthenticationResponse> login(
            @RequestBody AuthenticationRequest request
    ) {
        AuthenticationResponse response = authenticationService.login(request);
        return ResponseEntity.status(HttpStatus.OK).body(response);
    }
}

2.9. Test chức năng đăng nhập và các chức năng yêu cầu token

Như vậy là chúng ta đã thực hiện xong các logic phục vụ cho việc login. Chúng ta sẽ sử dụng Postman để tạo ra một user mới sau khi đã update logic insert password vào database: image.png Tiếp theo chúng ta dùng user này để đăng nhập: image.png Test các api khác yêu cầu đăng nhập. Để làm được điều này chúng ta cần copy token vào phần Authorization: image.png Và đây là kết quả sau khi đã có token: image.png

2.10. Kết

Vậy là chúng ta đã hoàn thành việc xây dựng ứng dụng sử dụng Spring Security và JWT token, vì bài viết khá dài nên mình sẽ không thêm phần exception vào để bắt lỗi trong quá trình xác thực. Tuy nhiên đôi khi do bị thiếu một phần nào đó thì có thể Spring Boot không báo lỗi nhưng Postman vẫn báo lỗi 401. Để có thể tìm ra được nguyên nhân cũng như giúp mọi người nắm rõ được luồng của phần login này, mình khuyên mọi người hãy đặt debug ở từng file mà logic đi qua, khi đó mọi người sẽ đọc được lỗi và có phương án xử lý hợp lý nhất. Còn nếu vẫn không xử lý được thì có thể post lên đây để tất cả mọi người có thể support cho bạn. Về phần Oauth2 mình sẽ viết trong phần sau. Cám ơn mọi người đã đọc bài viết ạ.


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.