Mục tiêu
- Tối ưu hiệu năng bằng việc chỉ lấy token khi cần thiết.
- Sử dụng Method Security và HttpSecurity để thiết lập phân quyền.
- Phân quyền truy cập bằng roles.
- Kiểm soát session từ phía máy chủ và đăng xuất từ phía backend.
- Dễ dàng tích hợp với các phương thức xác thực. Đơn giản và dễ sử dụng.
Kiến thức cần thiết
Vòng đời của một Request trong Spring
- Client gửi một request lên Server. Request đó sẽ đi qua lần lượt các Filter được cài đặt sẵn trong ứng dụng Spring Boot. Các Filter này có thể thực hiện các chức năng như xác thực, kiểm tra quyền truy cập, hoặc thực hiện các xử lý trước khi request đến được Controller.
- Sau khi đi qua các Filter, Request được chuyển đến Controller. Controller làm nhiệm vụ xử lý nghiệp vụ liên quan đến request như xử lý dữ liệu, gọi các service hoặc repository để truy xuất dữ liệu, và chuẩn bị dữ liệu cho Response.
- Sau khi Controller xử lý xong, dữ liệu Response được tạo ra. Response này chứa kết quả của xử lý request và các thông tin khác như headers và status code.
- Response đi ngược lại qua các Filter mà Request đã đi qua. Các Filter này có thể thực hiện các xử lý sau khi Controller đã hoàn thành việc xử lý request, ví dụ như ghi log, thêm hoặc sửa đổi headers, hoặc nén dữ liệu Response.
- Cuối cùng, dữ liệu Response được trả về cho Client. Client có thể nhận và xử lý dữ liệu Response theo nhu cầu của ứng dụng.
Cách Spring Security sử dụng filter để kiểm tra phân quyền.
- Spring Security sử dụng các Filter để kiểm tra và quản lý quyền truy cập của người dùng.
- Khi tạo một dự án Spring Security sử dụng Session, khi bạn truy cập vào trang web bằng trình duyệt và kiểm tra phần Developer Tools -> Application -> Cookies, bạn sẽ thấy rằng Spring Security tạo một cookie có tên là với giá trị có dạng
BED8B60A50738BEE47366C2F0ACBE9C2
. - Khi Client gửi Request lần đầu tiên lên Server, Server sẽ tạo một JSESSIONID đại diện cho Session của Client. Sau đó, Server gửi JSESSIONID này về Client dưới dạng một Cookie với tên là
JSESSIONID
. - Khi Client gửi các Request tiếp theo, trình duyệt sẽ tự động đính kèm Cookie JSESSIONID vào mỗi Request. Server nhận được Cookie này để xác định người dùng gửi Request đó.
Khi sử dụng Spring Security Session, bạn hãy thử xoá Cookie JSESSIONID sau đó truy cập lại trang web, Server sẽ không tìm thấy Session, bạn sẽ ngay lập tức logout.
Lưu ý rằng khi sử dụng Basic HttpSecurity, khi xoá JSESSIONID sẽ không tạo hiệu ứng trên vì nó sử dụng một kỹ thuật khác để xác định Session.
- Khi kiểm tra trạng thái đăng nhập của Session hiện tại, Spring Security SessionManagementFilter gọi hàm
this.securityContextHolderStrategy.getContext().getAuthentication();
. - Authentication Context chứa thông tin đã đăng nhập của người dùng.
Làm thế nào để tích hợp JWT.
Người dùng đăng nhập thành công, thay vì sử dụng JSESSIONID Server sẽ gán JWT token vào Cookie, mọi Request được gửi lên sẽ kèm theo JWT Cookie đó.
Ý tưởng ở đây chúng ta sẽ đặt một Filter nằm trước những Spring Security Filter cần kiểm tra Authentication Context. Filter đó đảm nhiệm việc lấy thông tin đang nhập của Session hiện tại nếu có.
LazySecurityContextProviderFilter chịu trách nhiệm xử lý logic JWT Token, yêu cầu làm mới Token khi cần thiết.
Lý do đặt JWT ở Cookie thay vì Request Header: Có thể kiểm soát session từ phía máy chủ và chủ động đăng xuất từ phía backend. Điều này cho phép quản lý session từ phía máy chủ, bao gồm việc kiểm soát thời gian sống của session và có thể đăng xuất người dùng từ phía máy chủ.
Thiết lập dự án
Cấu trúc thư mục
Thư viện cần có
- Java 17
- Spring Boot 3.x
- Spring Security 6.x
- com.auth0.java-jwt-4.4.0
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.2</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.huyvu</groupId> <artifactId>springsecurityjwt</artifactId> <version>0.0.1-SNAPSHOT</version> <name>springsecurityjwt</name> <description>springsecurityjwt</description> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>4.4.0</version> </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.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> <optional>true</optional> </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> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> </plugins> </build> </project>
SecurityUtils.java
package com.huyvu.springsecurityjwt.security; import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.Assert; import java.util.Arrays;
import java.util.Date; @Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class SecurityUtils { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final String AUTHORIZATION_HEADER = "Authorization"; private static final String AUTHORIZATION_PREFIX = "Bearer_"; private static final int SIX_HOURS_MILLISECOND = 1000 * 60 * 60 * 6; private static final int SIX_HOURS = 3600 * 6; private static final String USER_CLAIM = "user"; private static final String ISSUER = "auth0"; @Value("${jwt-key}") private static String SECRET_KEY = "iloveu3000"; private static final Algorithm ALGORITHM = Algorithm.HMAC256(SECRET_KEY); @SneakyThrows public static String createToken(JwtTokenVo jwtTokenVo) { var builder = JWT.create(); var tokenJson = OBJECT_MAPPER.writeValueAsString(jwtTokenVo); builder.withClaim(USER_CLAIM, tokenJson); return builder .withIssuedAt(new Date()) .withIssuer(ISSUER) .withExpiresAt(new Date(System.currentTimeMillis() + SIX_HOURS_MILLISECOND)) .sign(ALGORITHM); } @SneakyThrows public static DecodedJWT validate(String token) { var verifier = JWT.require(ALGORITHM) .withIssuer(ISSUER) .build(); return verifier.verify(token); } @SneakyThrows public static JwtTokenVo getValueObject(DecodedJWT decodedJWT) { var userClaim = decodedJWT.getClaims().get(USER_CLAIM).asString(); return OBJECT_MAPPER.readValue(userClaim, JwtTokenVo.class); } public static String getToken(HttpServletRequest req) { var cookies = req.getCookies(); var authCookie = Arrays.stream(cookies) .filter(e -> e.getName().equals(AUTHORIZATION_HEADER)) .findFirst() .orElseThrow(); String authorizationHeader = authCookie.getValue(); Assert.isTrue(authorizationHeader.startsWith(AUTHORIZATION_PREFIX), "Authorization header must start with '" + AUTHORIZATION_PREFIX + "'."); String jwtToken = authorizationHeader.substring(AUTHORIZATION_PREFIX.length()); return jwtToken; } public static void setToken(HttpServletResponse res, String token) { var cookie = new Cookie(AUTHORIZATION_HEADER, AUTHORIZATION_PREFIX + token); cookie.setMaxAge(SIX_HOURS); cookie.setPath("/"); res.addCookie(cookie); } public static JwtTokenVo getSession(){ var authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication instanceof AnonymousAuthenticationToken) { throw new AccessDeniedException("Not authorized."); } return (JwtTokenVo) authentication.getPrincipal(); }
}
- Lớp này được dùng để tạo, giải mã, đọc Token từ Request và ghi Token vào Response, các lớp Business sẽ sử dụng hàm
getSession()
để lấy thông tin đăng nhập hiện tại. - Token sử dụng thuật toán HMAC256, sử dụng SECRET_KEY.
- Key của Cookie là AUTHORIZATION_HEADER, value của JWT token nên được bắt đầu bằng
Bearer
. - Khi tạo Token chúng ta parse POJO
JwtTokenVo
thành JSON và thêm vào Claims. - Token Cookie sẽ được ghi vào Response.
JwtTokenVo.java
package com.huyvu.springsecurityjwt.security; import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.FieldDefaults;
import org.springframework.security.core.GrantedAuthority; import java.util.ArrayList;
import java.util.List; import static lombok.AccessLevel.PRIVATE; @Data
@AllArgsConstructor
@NoArgsConstructor
@FieldDefaults(level = PRIVATE)
public class JwtTokenVo { Long uId; String username; List<String> roles; // Thêm những thông tin cần thiết vào properties List<GrantedAuthority> getAuthorities() { if (roles == null) return new ArrayList<>(); return roles.stream().map(s -> (GrantedAuthority) () -> s).toList(); } }
- Lớp POJO để lưu thông tin đăng nhập vào JWT.
- Nếu cần lưu thêm những thông tin cần thiết các bạn có thể thêm vào lớp này.
LazySecurityContextProviderFilter.java
Sử lý logic JWT.
package com.huyvu.springsecurityjwt.security; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException;
import java.util.Date; import static javax.management.timer.Timer.ONE_HOUR; @Slf4j
@Component
@RequiredArgsConstructor
public class LazySecurityContextProviderFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException { var context = SecurityContextHolder.getContext(); SecurityContextHolder.setContext(new LazyJwtSecurityContextProvider(req, res, context)); filterChain.doFilter(req, res); } @RequiredArgsConstructor static class LazyJwtSecurityContextProvider implements SecurityContext { private final HttpServletRequest req; private final HttpServletResponse res; private final SecurityContext securityCtx; @Override public Authentication getAuthentication() { if (securityCtx.getAuthentication() == null || securityCtx.getAuthentication() instanceof AnonymousAuthenticationToken) { try { var jwtToken = SecurityUtils.getToken(this.req); var decodedJWT = SecurityUtils.validate(jwtToken); // Kiểm tra thời gian hết hạn của token if (decodedJWT.getExpiresAt().before(new Date())) { throw new AuthenticationServiceException("Token expired."); } var valueObject = SecurityUtils.getValueObject(decodedJWT); var authToken = new PreAuthenticatedAuthenticationToken(valueObject, null, valueObject.getAuthorities()); // Kiểm tra thời gian tồn tại của token, nếu hơn một giờ thì làm mới if(decodedJWT.getExpiresAt().before(new Date(System.currentTimeMillis() + ONE_HOUR))){ var token = SecurityUtils.createToken(valueObject); SecurityUtils.setToken(res, token); } authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(req)); securityCtx.setAuthentication(authToken); } catch (Exception e) { log.debug("Can't get authentication context: " + e.getMessage()); } } return securityCtx.getAuthentication(); } @Override public void setAuthentication(Authentication authentication) { securityCtx.setAuthentication(authentication); } } }
- Lớp Filter này đảm nhiệm việc xử lý logic khi cần thấy thông tin đăng nhập từ Request.
- Sử dụng design pattern Decorator, lớp chỉ thực hiện lấy Token khi được yêu cầu chứ không phải mọi Request. Điều này sẽ tối ưu hiệu năng của ứng dụng.
- Khi lấy Token lớp sẽ đồng thời kiểm tra Refresh Token.
SecurityConfig.java
package com.huyvu.springsecurityjwt.security; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.session.SessionManagementFilter; @Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true) // Bật tính năng Method Security
public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http, LazySecurityContextProviderFilter lazySecurityContextProviderFilter) throws Exception { http .sessionManagement(se -> se.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // Tắt tính năng Session In Memory Management trong Spring Security .authorizeHttpRequests(a -> { a.requestMatchers("/secured/**").authenticated(); a.requestMatchers("/admin/**").hasAuthority("admin"); a.anyRequest().permitAll(); }) .addFilterAfter(lazySecurityContextProviderFilter, SessionManagementFilter.class); // Đặt LazySecurityContextProviderFilter đứng trước SessionManagementFilter return http.build(); } }
HomeController.java
package com.huyvu.springsecurityjwt.controller; import com.huyvu.springsecurityjwt.security.JwtTokenVo;
import com.huyvu.springsecurityjwt.security.SecurityUtils;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Role;
import org.springframework.security.access.annotation.Secured;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController; import java.util.Arrays; @RestController
public class HomeController { @GetMapping String signing(Long uId, String username, String[] roles, HttpServletResponse res){ var token = SecurityUtils.createToken(new JwtTokenVo(uId, username, Arrays.stream(roles).toList())); SecurityUtils.setToken(res, token); return "signed"; } @GetMapping("/secured") String secured(){ var session = SecurityUtils.getSession(); return "Secured " + session; } @GetMapping("/admin") String admin(){ var session = SecurityUtils.getSession(); return "Admin " + session; } @PreAuthorize("hasAuthority('guest')") @GetMapping("/guest") String guest(){ var session = SecurityUtils.getSession(); return "Guest " + session; } @GetMapping("/business") String business(){ var session = SecurityUtils.getSession(); return "Business " + session; }
}
Chúng ta tạo Controller để test các trường hợp.
Controller | Test case |
---|---|
signing |
Nhận các biến đầu vào để mô phỏng chức năng đăng nhập |
secured |
Yêu cầu Session đã đăng nhập thành công, sử dụng HttpSecurity |
admin |
Yêu cầu Session đã đăng nhập thành công và có role là admin , sử dụng HttpSecurity |
guest |
Yêu cầu Session đã đăng nhập thành côngvà có role là guest , sử dụng Method Security |
business |
Yêu cầu Session đã đăng nhập thành công, trong trường hợp không sử dụng HttpSecurity và Method Security nhưng vẫn lấy thông tin đăng nhập hiện tại, ứng dụng sẽ trả lỗi AccessDeniedException |
Testing
Không đăng nhập
Đăng nhập với quyền admin, user, guest
Đăng nhập với quyền user
Source: https://github.com/huyvu8051/springsecurityjwt
Tại sao lại phải sử dụng lazy security context provider? Tại sao phải parse JwtTokenVo thành json string rồi mới thêm vào JWT claims? Tại sao lại exclude UserDetailsServiceAutoConfig? Tại sao đặt lazySecurityContextProviderFilter nằm trước SessionManagementFilter mà không phải nằm đầu tiên hoặc vị trí khác?