+4

Spring Security 6.x JWT Refresh Token Phân quyền đơn giản nhất

image.png

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

image.png

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

image.png

  • 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.

image.png

  • 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.

image.png

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

.
├── mvnw
├── mvnw.cmd
├── pom.xml
├── springsecurityjwt
├── src
│   ├── main
│   │   ├── java
│   │   │   └── com
│   │   │       └── huyvu
│   │   │           └── springsecurityjwt
│   │   │               ├── SpringsecurityjwtApplication.java
│   │   │               ├── controller
│   │   │               │   └── HomeController.java
│   │   │               └── security
│   │   │                   ├── JwtTokenVo.java
│   │   │                   ├── LazySecurityContextProviderFilter.java
│   │   │                   ├── SecurityConfig.java
│   │   │                   └── SecurityUtils.java
│   │   └── resources
│   │       └── application.properties
│   └── test
│       └── java
│           └── com
│               └── huyvu
│                   └── springsecurityjwt
│                       └── SpringsecurityjwtApplicationTests.java
30 directories, 21 files

Thư viện cần có

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 org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

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);
    }

    public static void setJwtToClient(JwtTokenVo vo){
        var token = createToken(vo);
        var cookie = new Cookie(AUTHORIZATION_HEADER, AUTHORIZATION_PREFIX + token);
        cookie.setMaxAge(SIX_HOURS);
        cookie.setPath("/");
        var attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        attributes.getResponse().addCookie(cookie);
    }

    @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 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);

                    if (decodedJWT.getExpiresAt().before(new Date())) {
                        throw new AuthenticationServiceException("Token expired.");
                    }

                    var jwtTokenVo = SecurityUtils.getValueObject(decodedJWT);
                    var authToken = new PreAuthenticatedAuthenticationToken(jwtTokenVo, null, jwtTokenVo.getAuthorities());

                    if(decodedJWT.getExpiresAt().before(new Date(System.currentTimeMillis() + ONE_HOUR))){
                        SecurityUtils.setJwtToClient(jwtTokenVo);
                    }

                    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.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){
        var jwtTokenVo = new JwtTokenVo(uId, username, Arrays.stream(roles).toList());
        SecurityUtils.setJwtToClient(jwtTokenVo);
        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 HttpSecurityMethod 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

Trong trạng thái chưa đăng nhập, trình duyệt chưa được cấp JWT Cookie nên khi truy cập các trang đều trả về lỗi Access Denied. image.png image.png image.png image.png

Đăng nhập với quyền admin, user, guest

image.png

  • Sau khi đăng nhập bằng Endpoint localhost:8080/ với các biến truyền vào Url, Server trả về cho trình duyệt Cookie có tên là Authorization. image.png image.png image.png image.png

Đăng nhập với quyền user

image.png

  • Đăng nhập chỉ với quyền user, Client sẽ không thể truy cập các Endpoint yêu cầu quyền adminguest image.png image.png image.png image.png

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?


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í