JWT
Trong thế giới phát triển ứng dụng hiện đại, bảo mật và xác thực người dùng là những yếu tố then chốt. JSON Web Token (JWT) nổi lên như một tiêu chuẩn phổ biến và hiệu quả để thực hiện xác thực và ủy quyền. Vậy JWT là gì, tại sao nó lại quan trọng và cách sử dụng nó như thế nào? Hãy cùng khám phá trong bài viết này.
1. JWT (JSON Web Token) là gì
JSON Web Token (JWT) là một tiêu chuẩn mở (RFC 7519) được sử dụng để chia sẻ thông tin một cách an toàn giữa hai bên — thường là máy khách và máy chủ. Thông tin trong JWT được mã hóa dưới dạng JSON và có thể được xác minh và tin cậy vì nó được ký bằng chữ ký số (Digital signature). JWT thường được sử dụng để xác thực và ủy quyền người dùng trong các ứng dụng web.
2. Cấu tạo của JWT
JWT sẽ gồm 3 phần chính cách nhau bởi một dấu chấm:
Header
Payload
Signature
JWT sẽ có dạng như sau:
<header>.<payload>.<signature>
Header:
Header thông thường sẽ chứa thông tin về loại token và thuật toán sử dụng để ký ( ví dụ: HMAC, SHA256, RSA)
Header sẽ có định dạng như sau:
{
"alg": "HS256",
"typ": "JWT"
}
Payload:
Payload trong JWT chứa các claims.
Claims là tập hợp các thông tin đại diện cho một thực thể (object) (ví dụ : user_id) và một số thông tin đi kèm. Claims sẽ có dạng Key - Value. Nó được mã hóa dưới dạng Base64Url để dễ dàng chuyển qua các môi trường khác nhau. Payload không phải là mật, vì vậy không nên lưu trữ thông tin nhạy cảm trong payload mà không mã hóa.
Sau đây là các loại claims có thể có trong payload:
Registered claims: là các thành phần được xác định trước của claims. Thành phần này mặc dù không bắt buộc, nhưng là thành phần nên có để cung cấp một số chức năng và thông tin hữu ích.. Ví dụ:
iss
(Issuer): Người phát hành token.sub
(Subject): Chủ đề của token, thường là userId của người dùng.aud
(Audience): Người nhận token, thường là domain của ứng dụng sử dụng token.exp
(Expiration time): Thời gian hết hạn của token. Sau thời gian này, token sẽ không được chấp nhận.nbf
(Not Before): Thời gian trước đó token không được chấp nhận.iat
(Issued at): Thời gian mà token được phát hành.jti
(JWT ID): Mã định danh duy nhất cho JWT, có thể dùng để ngăn chặn việc tái sử dụng token.
Public claims: Những claims này có thể được định nghĩa bởi những người sử dụng JWTs. Chúng nên được đăng ký trong một không gian tên JSON để tránh xung đột.
Một số public claims điển hình :
name
: Thông tin tên nói chung của useremail
: Thông tin email của user.locale
: Địa chỉ của user.profile, picture
: Thông tin của trang web gửi đến.
Private claims: Những claims này là thông tin tùy chỉnh để chia sẻ giữa các bên mà token được phát hành. Đây có thể là bất kỳ thông tin nào mà người phát hành muốn gửi tới người nhận token.
Signature:
Đây là phần thứ ba của JWT và được sử dụng để xác minh tính xác thực của token. Header và payload được mã hóa BASE64URL được nối lại với nhau bằng dấu chấm (.) và sau đó được băm sử dụng thuật toán băm được định nghĩa trong header với một khóa bí mật. Chữ ký này sau đó được thêm vào header và payload bằng dấu chấm (.), tạo thành token thực tế của chúng ta có dạng header.payload.signature.
Hình ảnh minh họa Signature được mã hóa bằng thuật toán HMACSHA256
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
3. Quy trình xác thực một JWT
Theo như mình đã nói ở trên thì JWT sẽ có dạng:
<header>.<payload>.<signature>
Đầu tiên sau khi nhận được JWT, hệ thống sẽ tách JWT ra các phần header, payload, signature. Tiếp theo, hệ thống sử dụng base64 URL decoding để giải mã phần header và payload. Sau khi decode, ta sẽ có được thông tin dạng JSON. Sau khi đã tách ra các thành phần chúng ta sẽ tạo signature từ header và payload mà ta đã lấy được ở trên và secretkey của hệ thống. So sánh signature vừa tạo với signature có trong JWT ta sẽ xác định được JWT có hợp lệ hay không. Cuối cùng chúng ta sẽ kiểm tra lại thông tin trong phần payload ví dự như là Expiration Time để xem token có hết hạn hay không. Sau khi xác thực thành công ta có thể thực hiện các bước xử lí logic tiếp theo với ứng dụng
4. Ưu điểm của JWT
Tính di động: JWT là một chuẩn mở và độc lập nền tảng, có thể sử dụng với nhiều ngôn ngữ lập trình khác nhau như Java, Python, Node.js, v.v.
Bảo mật: JWT sử dụng chữ ký số (HS256 hoặc RS256) để đảm bảo tính toàn vẹn và xác thực của thông tin. Chỉ những bên có khóa bí mật mới có thể tạo hoặc xác thực token.
Hiệu suất: JWT lưu trữ toàn bộ thông tin cần thiết bên trong token, giúp giảm số lần truy cập cơ sở dữ liệu để xác thực người dùng. Điều này tăng hiệu suất của hệ thống, đặc biệt là trong các ứng dụng phân tán hoặc microservices.
Khả năng mở rộng: JWT phù hợp với các ứng dụng microservices bởi vì thông tin xác thực có thể được truyền và xác thực giữa các service một cách độc lập mà không cần truy cập vào một cơ sở dữ liệu trung tâm.
Dễ dàng quản lý phiên làm việc: JWT không cần lưu trữ phiên làm việc trên server, giảm bớt gánh nặng quản lý session và tiết kiệm tài nguyên
5. Nhược điểm của JWT
Kích thước lớn: JWT có thể trở nên khá lớn do việc mã hóa thông tin dưới dạng JSON. Điều này có thể gây ảnh hưởng đến hiệu suất mạng, đặc biệt là khi truyền tải qua HTTP headers.
Không thể thu hồi (Revocation): Sau khi một JWT được tạo ra, không có cách nào để thu hồi hoặc vô hiệu hóa nó trừ khi thêm một cơ chế riêng biệt như danh sách đen (blacklist) token, điều này có thể phức tạp và tốn tài nguyên.
Hạn chế thời gian sống (Token Lifetime): Để giảm thiểu rủi ro bảo mật, thời gian sống của JWT thường được giới hạn. Điều này đòi hỏi cơ chế refresh token để cung cấp token mới khi hết hạn, gây thêm phức tạp trong triển khai.
Dễ dàng bị tấn công: Nếu khóa bí mật bị lộ hoặc token không được bảo vệ đúng cách (ví dụ: truyền qua HTTP thay vì HTTPS), JWT có thể dễ dàng bị tấn công và giả mạo.
Không phù hợp với các ứng dụng stateful: Trong các ứng dụng cần quản lý trạng thái phiên làm việc (stateful session), JWT có thể không phải là lựa chọn tốt nhất do tính chất stateless của nó.
6. Luồng hoạt động của JWT
1. Người dùng đăng nhập vào ứng dụng bằng tên đăng nhập và mật khẩu.
2. Sau khi xác thực tên đăng nhập và mật khẩu, ứng dụng sẽ tạo ra một JWT cho người dùng. JWT này bao gồm các thông tin về người dùng và các quyền truy cập của họ. JWT sẽ được mã hóa và ký số bằng khóa bí mật của ứng dụng.
3. JWT sẽ được trả lại cho người dùng.
4. Người dùng sẽ gửi JWT này đến server mỗi khi họ muốn truy cập các tài nguyên được bảo vệ của ứng dụng.
5. Server sẽ giải mã JWT bằng cách sử dụng khóa công khai của ứng dụng để xác thực tính hợp lệ của JWT.
6. Server sẽ trả lại các tài nguyên được yêu cầu cho người dùng.
7. Access Token và Refresh Token
Bản chất 2 loại trên đều là JSON Web Token. Bài toán đặt ra nếu chúng ta chỉ có một mã JWT để xác thực trong xuyên suốt quá trình sử dụng sản phẩm. Sẽ ra sao nếu hacker có thể lấy được JWT đó. Từ đó hacker có thể làm mọi điều dưới danh nghĩa của chúng ta.
—> Access Token được ra đời
Access token là một chuỗi token đại diện cho quyền truy cập của người dùng vào các tài nguyên được bảo vệ của ứng dụng. Đặc điểm của nó là sẽ hết hạn sau một khoảng thời gian ngắn.
Vậy nếu access token hết hạn, người dùng sẽ không sử dụng access token nữa từ đó phải đăng nhập lại. Bài toán mới đặt ra là nếu một access token sống trong khoảng 3 phút mà người dùng cần tương tác với server 30 phút. Vậy tức là người dùng sẽ phải đăng nhập lại 10 lần. Từ đó trải nghiệm của người dùng với sản phẩm sẽ cực kì tệ!
—> Refresh Token được ra đời
Refresh token là một chuỗi token được sử dụng để cập nhật access token khi nó đã hết hạn. Refresh token thường được sử dụng để giữ cho người dùng được đăng nhập và truy cập vào các tài nguyên được bảo vệ của ứng dụng trong một khoảng thời gian dài hơn. Khi access token hết hạn, refresh token sẽ được gửi đến server để lấy access token mới.
8. Demo luồng xác thực JWT trong dự án java spring boot
Đầu tiên ta dựng một dự án Spring với các dependency sau:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Chúng ta sẽ dựng một entity user với các thông số sau:
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "users")
public class User {
@Id
private String id;
private String username;
private String password;
private String fullName;
private String email;
}
Sau đó hãy viết 2 api đơn giản để thao tác với User trong example này mình sẽ làm api create và detail. Khi chạy chương trình và gọi 2 api bằng postman các bạn sẽ gặp trường hợp sau
Trong đoạn trên status sẽ trả ra cho chúng ta là 401 do chúng ta tích hợp spring security trong dự án nên chúng ta sẽ phải xác thực trước khi muốn gọi api.
Hãy thêm cho mình đoạn config sau trong phần config Security
@Bean
public SecurityFilterChain securityFilterChainUsersAPILocal(HttpSecurity httpSecurity) throws Exception {
sharedSecurityConfiguration(httpSecurity);
httpSecurity
.authorizeHttpRequests(auth -> {
auth.requestMatchers("api/v1/users/create", "api/v1/auth/**").permitAll();
auth.anyRequest().authenticated();
})
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(exception -> exception
.authenticationEntryPoint(unAuthenticationCustomHandler)
.accessDeniedHandler(unAuthorizationCustomHandler));
return httpSecurity.build();
}
Đoạn trên sẽ là đoạn config chính để sử dụng Spring Security, để xem đầy đủ các bạn hãy truy cập ở link github bên dưới. Trong đoạn config này mình sẽ cấu hình api “api/v1/users/create” và các api có dạng “api/v1/auth/” ở trước sẽ không cần phải xác thực. Tiếp theo bạn cần để ý dòng bên dưới mình sẽ thêm một bộ lọc “jwtAuthenticationFilter” dùng để xác thực JWT trước bộ lọc “UsernamePasswordAuthenticationFilter”. Sau khi config sau gọi lại api create ta sẽ được như sau:
Vậy là mình đã dựng xong base cơ bản để bắt đầu làm việc với JWT. Giờ đây mình đã có thể gọi api create User mà không cần phải xác thực, vậy tiếp theo làm sao để gọi api detail User. Mình sẽ demo theo luồng như mục 5 đã trình bày.
Đầu tiên các bạn hãy thêm cho mình các dependency sau để làm việc với JWT
<!-- JWT Dependencies -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
</dependency>
Mình sẽ tạo một interface chứa các phương thức để thao tác với JWT và triển khai nó
public interface TokenService {
String createToken(CreateTokenRequest request);
String getTokenSubject(String token, TokenType tokenType);
Boolean validateToken(String token, String usernameLogin, TokenType tokenType);
Claims extractAllClaims(String token, TokenType tokenType);
}
Ở đây ta sẽ quan tâm chính đến hàm createToken, Token sẽ được tạo như sau:
@Override
public String createToken(CreateTokenRequest request) {
log.info("=== Start create token with request: {}", request);
var now = new Date();
var expired = new Date(now.getTime() + request.expiredSeconds());
return Jwts.builder()
.setClaims(request.data())
.setSubject(String.valueOf(request.subject()))
.setIssuedAt(now)
.setExpiration(expired)
.signWith(getSecretKey(request.tokenType()), SignatureAlgorithm.HS256).compact();
}
Ở đây Token sẽ được xây dựng lên từ những thông số mình đã trình bày ở phần payload của JWT. Chúng ta sẽ set các thuộc tính cho phần payload sau đó sẽ ký JWT bằng một khóa bí mật trong lấy từ phương thức getSecretKey ( mình sẽ giải thích ở bên dưới) sử dụng thuật toán HS256 để mã hóa. Cuối cùng là “compact()” sẽ build JWT và trả về dưới dạng chuỗi.
Giờ ta sẽ xem về hàm SecretKey
private Key getSecretKey(TokenType tokenType) {
return switch (tokenType) {
case ACCESS_TOKEN ->
Keys.hmacShaKeyFor(
Decoders.BASE64.decode(propertiesConfiguration.getAccessTokenSecretKey())
);
case REFRESH_TOKEN ->
Keys.hmacShaKeyFor(
Decoders.BASE64.decode(propertiesConfiguration.getRefreshTokenSecretKey())
);
default -> {
log.info("TokenType: {} is not supported", tokenType);
throw new InternalServerError("Unsupported TokenType: " + tokenType);
}
};
}
Ở hàm trên chúng ta sẽ tạo ra một key instance từ việc gọi phương thức hmacShaKeyFor() để mã hóa mảng Byte từ việc decode secret key mình đã cấu hình trong file config được mã hóa ở dạng Base64. Từ đó ta sẽ tạo ra được một secret key dưới thuật toán HMAC.
Vậy là chúng ta đã xây dựng xong phương thức đã xây dựng JWT bây giờ ta sẽ đi đến bộ lọc JwtAuthenticationFilter mà ta đã đề cập ở trên. Đây sẽ là bộ lọc để xác thực JWT.
@Component
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenService tokenService;
private final UserService userService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
@NotNull HttpServletResponse response,
@NotNull FilterChain filterChain
) throws ServletException, IOException {
log.debug("=== Start filter jwt");
log.debug("Request: {}", request.toString());
final var accessTokenBearer = request.getHeader(AUTHORIZATION);
if (Objects.isNull(accessTokenBearer) || !accessTokenBearer.startsWith(BEARER_TOKEN_TYPE_START)) {
log.debug("Token null or invalid, token: {}", accessTokenBearer);
filterChain.doFilter(request, response);
return;
}
final var accessToken = accessTokenBearer.substring(BEARER_TOKEN_TYPE_START.length());
log.debug("Access_token: {}", accessToken);
try {
final var userId = tokenService.getTokenSubject(accessToken, TokenType.ACCESS_TOKEN);
log.debug("UserId: {}", userId);
final var user = userService.detail(userId);
log.debug("User: {}", user);
if (Objects.isNull(user)) {
log.debug("User not found with id: {}", userId);
filterChain.doFilter(request, response);
return;
}
var authentication = new UsernamePasswordAuthenticationToken(
user,
null,
new ArrayList<>()
);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
} catch (ExpiredJwtException e) {
handleException(e, response, HttpServletResponse.SC_UNAUTHORIZED, "Token expired");
} catch (SignatureException | MalformedJwtException e) {
handleException(e, response, HttpServletResponse.SC_UNAUTHORIZED, "Token invalid");
} catch (BaseException e) {
handleException(e, response, e.getStatus(), e.getMessage());
} catch (Exception e) {
handleException(e, response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Internal server error");
}
}
Luồng chính đoạn trên sẽ như sau đầu tiên ta sẽ lấy JWT từ header Authorization của yêu cầu HTTP. Tiếp theo đó ta sẽ kiểm tra xem token có hợp lệ không, nếu hợp lệ ta sẽ tiếp tục lấy ra thông tin người từ phần subject của token (ở đây phần subject mình để là user id). Nếu người dùng hợp lệ ta sẽ tạo mới một đối tượng UsernamePasswordAuthenticationToken() sau đó lưu vào SecurityContextHolder.
Vậy là ta đã sắp hoàn thành được phần demo rồi. Phần cuối cùng là ta sẽ xây dựng hàm login và trả về JWT cho người dùng
@Override
public LoginResponse login(LoginRequest request) {
User user = userService.findByUserName(request.username());
this.equalPassword(request.password(), user.getPassword());
var tokenData = this.buildTokenData(user);
final var accessToken = createToken(user.getId(), tokenData, TokenType.ACCESS_TOKEN);
final var refreshToken = createToken(user.getId(), tokenData, TokenType.REFRESH_TOKEN);
return new LoginResponse(
user.getId(),
accessToken,
refreshToken,
propertiesConfiguration.getAccessTokenTtl(),
propertiesConfiguration.getRefreshTokenTtl()
);
}
Sau khi login thành công thì ta sẽ trả về access token và refresh token cho người dùng. Thành quả của chúng ta sẽ được như ảnh dưới đây:
Giờ đây ta sẽ lấy access token cho vô phần Authorization với Type Bear Token để xác thực cho hàm detail. Và cuối cùng là thành quả của chúng ta
Ok vậy là mình đã hoàn thiện demo về luồng xác thực của JWT, phần này liên quan đến Spring Security nhưng mình chưa nhắc đến nhiều. nếu bạn muốn hiểu sâu hơn về Spring Security hãy theo dõi nhóm mình trong các bài viết sắp tới nhé!
Link github: demo-jwt