Skip to main content

2. Xác thực API bằng JWT

Trong phần này, chúng ta sẽ tìm hiểu cách xác thực API trong ứng dụng Java Spring Boot bằng JSON Web Token (JWT). JWT là một tiêu chuẩn mở (RFC 7519) cho phép truyền tải thông tin an toàn giữa các bên dưới dạng đối tượng JSON. Nó thường được sử dụng để xác thực người dùng trong các ứng dụng web và di động.

Thêm phụ thuộc vào dự án

Để sử dụng JWT trong Spring Boot, bạn cần thêm các phụ thuộc sau vào tệp build.gradle (sử dụng Gradle):

// Thêm vào build.gradle
// Security
implementation 'org.springframework.boot:spring-boot-starter-security'
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'

Yêu cầu trước khi làm việc với JWT

Để sử dụng JWT, bạn cần có một lớp người dùng (User) với các thuộc tính như username, password, và các quyền (roles) của người dùng. Bạn cũng cần một lớp UserJpaRepository để truy xuất thông tin người dùng từ cơ sở dữ liệu. Dưới đây là một ví dụ về lớp người dùng và repository:

Lớp người dùng (User)

Để đại diện cho người dùng trong hệ thống, bạn cần tạo một lớp User với các thuộc tính như id, username, password, và danh sách các quyền (roles) của người dùng. Dưới đây là một ví dụ về lớp này:

package com.example.demo.entities;

import java.util.List;

import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@Entity
@Table(name = "Users")

public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

private Long id;
private String username;
private String password;

@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "users_roles", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "role_id"))
private List<Role> roles;
}

Lớp quyền (Role)

Để quản lý quyền của người dùng, bạn cần tạo một lớp Role để đại diện cho các quyền khác nhau trong hệ thống. Dưới đây là một ví dụ về lớp này:

package com.example.demo.entities;

import java.util.List;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@Entity
@Table(name = "roles")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;

@ManyToMany(mappedBy = "roles")
private List<User> users;
}

Tạo lớp UserJpaRepository

Để truy xuất thông tin người dùng từ cơ sở dữ liệu, bạn cần tạo một lớp UserJpaRepository sử dụng Spring Data JPA. Lớp này sẽ cung cấp các phương thức để tìm kiếm người dùng theo tên đăng nhập (username). Dưới đây là một ví dụ về cách tạo lớp này:

package com.example.demo.repositories;

import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import com.example.demo.entities.User;

@Repository
public interface UserJpaRepository extends JpaRepository<User, Long> {
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.username = :username")
Optional<User> findByUsername(String username);
}

Tạo lớp UserService

Để xử lý logic xác thực người dùng, bạn cần tạo một lớp UserService. Lớp này sẽ sử dụng UserJpaRepository để tìm kiếm người dùng và xác thực thông tin đăng nhập. Dưới đây là một ví dụ về cách tạo lớp này:

package com.example.demo.services;

import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;

import com.example.demo.dtos.LoginRequestDto;
import com.example.demo.dtos.LoginResponseDto;
import com.example.demo.entities.User;
import com.example.demo.exceptions.HttpException;
import com.example.demo.repositories.UserJpaRepository;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Service
public class UserService {
private final JwtService jwtService;
private final UserJpaRepository userJpaRepository;

public LoginResponseDto login(LoginRequestDto request) throws Exception {
// Find the user by email (username)
User user = this.userJpaRepository.findByUsername(request.getUsername())
.orElseThrow(() -> new HttpException("Invalid username or password", HttpStatus.UNAUTHORIZED));

// Verify password
if (!request.getPassword().equals(user.getPassword())) {
throw new HttpException("Invalid username or password", HttpStatus.UNAUTHORIZED);
}

// Generate a new access token (with full data + roles)
String accessToken = jwtService.generateAccessToken(user);

return LoginResponseDto.builder()
.id(user.getId())
.username(user.getUsername())
.accessToken(accessToken)
.build();
}
}

Tạo lớp CustomUserDetailsService

Để sử dụng JWT, bạn cần tạo một lớp CustomUserDetailsService để tải thông tin người dùng từ cơ sở dữ liệu. Lớp này sẽ triển khai UserDetailsService và sử dụng UserJpaRepository để truy xuất thông tin người dùng. Dưới đây là một ví dụ về cách tạo lớp này:

package com.example.demo.services;

import java.util.ArrayList;
import java.util.List;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.example.demo.entities.User;
import com.example.demo.repositories.UserJpaRepository;

@Service
public class CustomUserDetailsService implements UserDetailsService {

private final UserJpaRepository userRepository;

public CustomUserDetailsService(UserJpaRepository userRepository) {
this.userRepository = userRepository;
}

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + username));

List<GrantedAuthority> authorities = new ArrayList<>();
user.getRoles().forEach(role -> {
// Nếu dùng @PreAuthorize("hasAuthority('Administrators')") thì
authorities.add(new SimpleGrantedAuthority(role.getName()));

// Nếu dùng @PreAuthorize("hasRole('Administrators')") thì authorities.add(new
authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getName()));
});

return org.springframework.security.core.userdetails.User.builder()
.username(user.getUsername())
.password(user.getPassword())
.authorities(authorities)
.build();
}
}

Tạo lớp JwtService

Tạo một lớp tiện ích để xử lý các chức năng liên quan đến JWT, bao gồm tạo, xác thực và phân tích JWT. Ví dụ:

package com.example.demo.services;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

import javax.crypto.SecretKey;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;

import com.example.demo.entities.User;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class JwtService {

private SecretKey getSigningKey() {
String secretKey = "MIsMiHz45ATNS6elM6dQLfN6oQIBDSV+KbAc5PE3rlA=";
byte[] keyBytes = io.jsonwebtoken.io.Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}

private String createToken(Map<String, Object> claims, String subject, long expiration) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);

return Jwts.builder()
.claims(claims)
.subject(subject)
.issuedAt(now)
.expiration(expiryDate)
.signWith(getSigningKey(), Jwts.SIG.HS256)
.compact();
}

public String generateAccessToken(User user) {
Map<String, Object> claims = new HashMap<>();
claims.put("id", user.getId());
claims.put("type", "access_token"); // Token type identifier

// Add user roles to access token
/*
* Trường hợp KHÔNG nên đưa roles vào JWT:
* Bảo mật cao: Roles có thể thay đổi thường xuyên, nếu để trong JWT thì phải
* chờ token hết hạn mới cập nhật được.
* Roles phức tạp: Nếu roles có nhiều thông tin chi tiết, JWT sẽ trở nên lớn.
* Quản lý tập trung: Muốn kiểm soát quyền truy cập real-time từ database.
* Trường hợp NÊN đưa roles vào JWT:
* Performance: Tránh query database mỗi request để lấy roles.
* Stateless: Hoàn toàn không phụ thuộc vào database cho việc xác thực.
* Microservices: Các service khác có thể đọc roles từ JWT mà không cần gọi user
* service.
*/
// List<Map<String, Object>> roles = user.getRoles().stream()
// .map(role -> {
// Map<String, Object> roleMap = new HashMap<>();
// roleMap.put("id", role.getId());
// roleMap.put("name", role.getName());
// return roleMap;
// })
// .collect(Collectors.toList());

// claims.put("roles", roles);

long jwtExpiration = 86400000;
return createToken(claims, user.getUsername(), jwtExpiration);
}

private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}

public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}

public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}

public String extractTokenType(String token) {
return extractClaim(token, claims -> claims.get("type", String.class));
}

public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}

private Boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}

public Boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
final String tokenType = extractTokenType(token);
return (username.equals(userDetails.getUsername()))
&& !isTokenExpired(token)
&& "access_token".equals(tokenType); // Only access tokens for authentication
}
}

Tạo lớp JwtAuthenticationFilter cho JWT

Tạo một lớp filter để kiểm tra JWT trong mỗi yêu cầu đến API. Lớp này sẽ xác thực token và thiết lập thông tin người dùng trong ngữ cảnh bảo mật của Spring Boot.

package com.example.demo.filters;

import java.io.IOException;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import com.example.demo.services.JwtService;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
String jwt = null;
String username = null;

if (authHeader != null && authHeader.startsWith("Bearer ")) {
jwt = authHeader.substring(7);
username = this.jwtService.extractUsername(jwt);
}

if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

if (jwtService.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails,
null, // No credentials are needed for JWT authentication
userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

// Đã xác thực người dùng, đặt thông tin xác thực vào SecurityContext
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}

filterChain.doFilter(request, response);
}
}

Cấu hình bảo mật Spring Security

Trong lớp cấu hình bảo mật của bạn, hãy đăng ký JwtRequestFilter để nó có thể kiểm tra JWT trong mỗi yêu cầu. Ví dụ:

package com.example.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import com.example.demo.exceptions.CustomAccessDeniedHandler;
import com.example.demo.exceptions.CustomAuthenticationEntryPoint;
import com.example.demo.filters.JwtAuthenticationFilter;

import lombok.RequiredArgsConstructor;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity()
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CustomAccessDeniedHandler customAccessDeniedHandler;
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(exceptionHandlingCustomizer -> exceptionHandlingCustomizer
.authenticationEntryPoint(this.customAuthenticationEntryPoint)
.accessDeniedHandler(this.customAccessDeniedHandler))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/students/**").hasAnyRole("Administrators", "Managers")
.anyRequest().permitAll())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

return http.build();
}
}

Tạo endpoint để xác thực và trả về JWT

Tạo một endpoint để người dùng có thể đăng nhập và nhận JWT. Ví dụ:

package com.example.demo.controllers;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.demo.dtos.LoginRequestDto;
import com.example.demo.dtos.LoginResponseDto;
import com.example.demo.services.UserService;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/auth")
public class AuthController {

private final UserService userService;

@PostMapping("/login")
public ResponseEntity<LoginResponseDto> login(@RequestBody LoginRequestDto request) throws Exception {
LoginResponseDto result = this.userService.login(request);
return ResponseEntity.ok(result);
}
}

Kiểm tra API

Bây giờ bạn có thể chạy ứng dụng Spring Boot và kiểm tra endpoint /auth/login bằng Postman hoặc công cụ tương tự. Gửi một yêu cầu POST với JSON body chứa tên người dùng và mật khẩu. Nếu thông tin đăng nhập hợp lệ, bạn sẽ nhận được JWT trong phản hồi. Sử dụng JWT này trong các yêu cầu tiếp theo bằng cách thêm nó vào header Authorization với định dạng Bearer <token>.

{
"username": "username",
"password": "password"
}

Kết luận

Trong phần này, chúng ta đã tìm hiểu cách xác thực API trong ứng dụng Java Spring Boot bằng JSON Web Token (JWT). Chúng ta đã tạo các lớp để xử lý JWT, cấu hình bảo mật Spring Security và tạo endpoint để người dùng đăng nhập và nhận JWT. JWT cung cấp một cách an toàn và hiệu quả để xác thực người dùng trong các ứng dụng web và di động. Để bảo mật JWT, bạn nên sử dụng một khóa bí mật mạnh và không công khai. Ngoài ra, bạn có thể cân nhắc các biện pháp bảo mật bổ sung như:

  • Làm mới token: Cung cấp endpoint để làm mới token khi nó sắp hết hạn.
  • Phân quyền: Thêm thông tin phân quyền vào JWT để kiểm soát quyền truy cập đến các endpoint khác nhau.
  • Xử lý lỗi: Xử lý các trường hợp lỗi khi token không hợp lệ hoặc hết hạn, trả về mã lỗi phù hợp (ví dụ: 401 Unauthorized).