在 Spring Boot 中使用 Spring Security + JWT + MySQL 实现基于 Token 的身份认证

本文将会带你了解在 Spring Boot 中如何使用 Spring Security、JWT 和 MySQL 数据库实现基于 Token 的身份认证。

JWT (JSON Web Token)概览

JWT 是 JSON Web Token 的缩写,是一种安全地在各方之间传输信息的开放标准。它是一种紧凑、自包含的数据传输方法,通常用于客户端和服务器之间的数据传输。

JWT 通常用于认证和授权,服务器通过验证 JWT 中包含的数字签名来验证用户。

JWT 由三部分组成:Header、Payload 和 Signature(签名)。

  • Header 包含 Token 类型和 Token 签名算法的元数据。
  • Payload 包含关于被验证用户或实体的声明(Claim)或陈述。这些声明可包括用户 ID、用户名或电子邮件地址等信息。
  • Signature(签名)使用秘钥和 Header 及 Payload 生成,以确保 JWT 的完整性。

使用 JWT 的一个好处是它们是无状态的,这意味着服务器无需跟踪用户的身份认证状态。这可以提高可扩展性和性能。此外,JWT 可以在不同的域和服务中使用,只要它们共享相同的秘钥来验证签名即可。

Spring Security 概览

Spring Security 是一个提供身份认证、授权和防护常见攻击的框架。它为确保 Web 和响应式应用程序的安全提供一流的支持,是保护基于 Spring 的应用程序的事实标准。

Spring Security 用于保护 Web 应用程序、REST API 和微服务的安全,为身份认证和授权提供内置支持。

数据库表结构

数据库表关系图

添加 Maven 依赖

添加如下依赖到 Spring Boot 项目:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</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-security</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-jackson -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>

配置 MySQL 数据库

通过如下命令在 MySQL 中创建一个数据库,名为 login_system

create database login_system

使用 MySQL数据库,需要配置数据库 URL、用户名和密码,以便 Spring 能在启动时与数据库建立连接。编辑 src/main/resources/application.properties 文件,添加以下属性:

spring.datasource.url = jdbc:mysql://localhost:3306/login_system
spring.datasource.username = root
spring.datasource.password = root

# Hibernate ddl auto (create, create-drop, validate, update)
spring.jpa.hibernate.ddl-auto = update

logging.level.org.springframework.security=DEBUG

Model 层 - 创建 JPA 实体

创建 UserRole JPA 实体,并在它们之间建立 多对多(MANY-to-MANY) 关系。

使用 JPA 注解在 UserRole 实体之间建立 多对多 关系。

User

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.util.Set;

@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    @Column(nullable = false, unique = true)
    private String username;
    @Column(nullable = false, unique = true)
    private String email;
    @Column(nullable = false)
    private String password;

    @ManyToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
    @JoinTable(name = "users_roles",
        joinColumns = @JoinColumn(name = "user_id", referencedColumnName = "id"),
            inverseJoinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id")
    )
    private Set<Role> roles;
}

Role

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "roles")
public class Role {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
}

Repository 层

UserRepository

import net.javaguides.todo.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByUsername(String username);

    Boolean existsByEmail(String email);

    Optional<User> findByUsernameOrEmail(String username, String email);

    boolean existsByUsername(String username);
}

RoleRepository

import net.javaguides.todo.entity.Role;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Map;
import java.util.Optional;

public interface RoleRepository extends JpaRepository<Role, Long> {
    Optional<Role> findByName(String name);
}

JWT 实现

在 Spring boot 项目中创建一个 security 包,并添加以下与 JWT 相关的类。

JwtAuthenticationEntryPoint

创建 JwtAuthenticationEntryPoint 类,如下:

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {

        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
    }
}

AuthenticationEntryPointExceptionTranslationFilter 用来启动身份认证方案。它是一个入口点,用于检查用户是否已通过身份认证,如果用户已经认证,则登录该用户,否则抛出异常(unauthorized)。通常情况下,在简单的应用程序中可以直接使用该类,但当在 REST、JWT 等中使用 Spring Security 时,就必须对其进行继承,以提供更好的 Spring Security 过滤器链(filter chain)管理。

JWT - 修改 application.properties

application.properties 文件中添加以下两个与 JWT 相关的属性:

app.jwt-secret=daf66e01593f61a15b857cf433aae03a005812b31234e149036bcc8dee755dbb
app-jwt-expiration-milliseconds=604800000

JTW 工具类 - JwtTokenProvider

创建一个名为 JwtTokenProvider 工具类,用于生成、验证 JWT 以及从 JWT 中提取信息。

import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Date;

@Component
public class JwtTokenProvider {

    private static final Logger logger = LoggerFactory.getLogger(JwtTokenProvider.class);

    @Value("${app.jwt-secret}")
    private String jwtSecret;

    @Value("${app-jwt-expiration-milliseconds}")
    private long jwtExpirationDate;

    // 生成 JWT token
    public String generateToken(Authentication authentication){
        String username = authentication.getName();

        Date currentDate = new Date();

        Date expireDate = new Date(currentDate.getTime() + jwtExpirationDate);

        String token = Jwts.builder()
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(expireDate)
                .signWith(key())
                .compact();
        return token;
    }

    private Key key(){
        return Keys.hmacShaKeyFor(
                Decoders.BASE64.decode(jwtSecret)
        );
    }

    // 从 Jwt token 获取用户名
    public String getUsername(String token){
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(key())
                .build()
                .parseClaimsJws(token)
                .getBody();
        String username = claims.getSubject();
        return username;
    }

    // 验证 Jwt token
    public boolean validateToken(String token){
        try{
            Jwts.parserBuilder()
                    .setSigningKey(key())
                    .build()
                    .parse(token);
            return true;
        } catch (MalformedJwtException e) {
            logger.error("Invalid JWT token: {}", e.getMessage());
        } catch (ExpiredJwtException e) {
            logger.error("JWT token is expired: {}", e.getMessage());
        } catch (UnsupportedJwtException e) {
            logger.error("JWT token is unsupported: {}", e.getMessage());
        } catch (IllegalArgumentException e) {
            logger.error("JWT claims string is empty: {}", e.getMessage());
        }
        return false;
    }
}

generateToken 方法

public String generateToken(Authentication authentication){
    String username = authentication.getName();

    Date currentDate = new Date();

    Date expireDate = new Date(currentDate.getTime() + jwtExpirationDate);

    String token = Jwts.builder()
            .setSubject(username)
            .setIssuedAt(new Date())
            .setExpiration(expireDate)
            .signWith(key())
            .compact();
    return token;
}

generateToken(Authentication authentication) 方法根据提供的 Authentication 对象生成一个新的 JWT,该对象包含被验证用户的信息。它使用 Jwts.builder() 方法创建一个新的 JwtBuilder 对象,设置 JWT 的 subject(即用户名)、发布日期(issue date)和到期日期(expiration date),并使用 key() 方法对 JWT 进行签名。最后,它会以字符串形式返回 JWT。

getUsername(String token)

// 从 Jwt token 获取用户名
public String getUsername(String token){
    Claims claims = Jwts.parserBuilder()
            .setSigningKey(key())
            .build()
            .parseClaimsJws(token)
            .getBody();
    String username = claims.getSubject();
    return username;
}

getUsername(String token) 方法从提供的 JWT 中提取 username。该方法使用 Jwts.parserBuilder() 方法创建一个新的 JwtParserBuilder 对象,使用 key() 方法设置签名密钥(Signing Key),并使用 parseClaimsJws() 方法解析 JWT。然后,它会从 JWT 的 Claims 对象中获取 subject(即用户名),并以字符串形式返回。

validateToken(String token)

// 校验 Jwt token
public boolean validateToken(String token){
    try{
        Jwts.parserBuilder()
                .setSigningKey(key())
                .build()
                .parse(token);
        return true;
    } catch (MalformedJwtException e) {
        logger.error("Invalid JWT token: {}", e.getMessage());
    } catch (ExpiredJwtException e) {
        logger.error("JWT token is expired: {}", e.getMessage());
    } catch (UnsupportedJwtException e) {
        logger.error("JWT token is unsupported: {}", e.getMessage());
    } catch (IllegalArgumentException e) {
        logger.error("JWT claims string is empty: {}", e.getMessage());
    }
    return false;
}

validateToken(String token) 方法会验证所提供的 JWT。该方法使用 Jwts.parserBuilder() 方法创建一个新的 JwtParserBuilder 对象,使用 key() 方法设置签名密钥,并使用 parse() 方法解析 JWT。如果 JWT 有效,该方法会返回 true。如果 JWT 无效或已过期,该方法会使用 logger 对象输出错误信息并返回 false

JwtAuthenticationFilter

在 Spring Boot 应用程序中创建 JwtAuthenticationFilter 类,该类可拦截传入的 HTTP 请求并验证包含在 Authorization 头中的 JWT Token。如果 Token 有效,Filter 就会在 SecurityContext 中设置当前用户的 Authentication

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private JwtTokenProvider jwtTokenProvider;

    private UserDetailsService userDetailsService;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider, UserDetailsService userDetailsService) {
        this.jwtTokenProvider = jwtTokenProvider;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        // 从 request 获取 JWT token
        String token = getTokenFromRequest(request);

        // 校验 token
        if(StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)){

            // 从 token 获取 username
            String username = jwtTokenProvider.getUsername(token);

            // 加载与令 token 关联的用户
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                userDetails,
                null,
                userDetails.getAuthorities()
            );

            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

            SecurityContextHolder.getContext().setAuthentication(authenticationToken);

        }

        filterChain.doFilter(request, response);
    }

    private String getTokenFromRequest(HttpServletRequest request){

        String bearerToken = request.getHeader("Authorization");

        if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")){
            return bearerToken.substring(7, bearerToken.length());
        }

        return null;
    }
}

上述代码的关键点如下:

  • 该类继承了 Spring 的 OncePerRequestFilter,可确保每个请求只执行一次过滤器。
  • 构造函数需要两个依赖:JwtTokenProviderUserDetailsService,它们是通过 Spring 的构造函数依赖注入机制注入的。
  • doFilterInternal 方法是 Filter 的主要逻辑。它使用 getTokenFromRequest 方法从 Authorization Header 中提取 JWT Token,使用 JwtTokenProvider 类验证 Token,并在 SecurityContextHolder 中设置 Authentication 信息。
  • getTokenFromRequest 方法会解析 Authorization Header,并返回 Token 部分。
  • SecurityContextHolder 用于存储当前 request 的 Authentication 信息。在这种情况下,Filter 会将 UsernamePasswordAuthenticationToken 与该 Token 关联的 UserDetailsauthorities(授权)设置在一起。

CustomUserDetailsService

创建一个 Service,根据 nameemail 从数据库中加载用户详细信息。

创建一个 CustomUserDetailsService,它实现了 UserDetailsService 接口(Spring Security 内置接口),并提供了 loadUserByUername() 方法的实现:

import lombok.AllArgsConstructor;
import net.javaguides.todo.entity.User;
import net.javaguides.todo.repository.UserRepository;
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 java.util.Set;
import java.util.stream.Collectors;

@Service
@AllArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String usernameOrEmail) throws UsernameNotFoundException {

        User user = userRepository.findByUsernameOrEmail(usernameOrEmail, usernameOrEmail)
                .orElseThrow(() -> new UsernameNotFoundException("User not exists by Username or Email"));

        Set<GrantedAuthority> authorities = user.getRoles().stream()
                .map((role) -> new SimpleGrantedAuthority(role.getName()))
                .collect(Collectors.toSet());

        return new org.springframework.security.core.userdetails.User(
                usernameOrEmail,
                user.getPassword(),
                authorities
        );
    }
}

Spring Security 使用 UserDetailsService 接口,该接口包含 loadUserByUsername(String username) 方法,用于查找给定 usernameUserDetails

UserDetails 接口代表一个经过认证的用户对象,Spring Security 提供了 org.springframework.security.core.userdetails.User 的开箱即用实现。

Spring Security 配置

创建 SpringSecurityConfig 类,并添加以下配置:

import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@AllArgsConstructor
public class SpringSecurityConfig {

    private UserDetailsService userDetailsService;

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

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        http.csrf().disable()
                .authorizeHttpRequests((authorize) -> {
                      authorize.requestMatchers("/api/auth/**").permitAll();
                    authorize.anyRequest().authenticated();
                });
        return http.build();
    }

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

@Configuration 注解表示该类定义了 Spring Application Context 的配置。

@AllArgsConstructor 注解来自 Lombok 库,它会生成一个包含所有用 @NonNull 注解的字段的构造函数。

passwordEncoder() 方法是一个 Bean,用于创建 BCryptPasswordEncoder 实例,对密码进行编码。

securityFilterChain() 方法是一个定义安全过滤器链(Security Filter Chain)的 Bean。HttpSecurity 参数用于配置应用程序的安全设置。在本例中,该方法禁用 CSRF 保护,并根据 HTTP 方法和 URL 授权请求。

authenticationManager() 方法是一个提供 AuthenticationManager 的 Bean。它从 AuthenticationConfiguration 实例中检索 Authentication Manager。

Service 层

创建一个 service 包,并添加以下与 service 层相关的 AuthService 接口和 AuthServiceImpl 类。

AuthService 接口

import net.javaguides.todo.dto.LoginDto;

public interface AuthService {
    String login(LoginDto loginDto);
}

AuthServiceImpl 类

import net.javaguides.todo.dto.LoginDto;
import net.javaguides.todo.repository.RoleRepository;
import net.javaguides.todo.repository.UserRepository;
import net.javaguides.todo.security.JwtTokenProvider;
import net.javaguides.todo.service.AuthService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.HashSet;
import java.util.Set;

@Service
public class AuthServiceImpl implements AuthService {

    private AuthenticationManager authenticationManager;
    private UserRepository userRepository;
    private PasswordEncoder passwordEncoder;
    private JwtTokenProvider jwtTokenProvider;


    public AuthServiceImpl(
            JwtTokenProvider jwtTokenProvider,
            UserRepository userRepository,
            PasswordEncoder passwordEncoder,
            AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    public String login(LoginDto loginDto) {

        Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(
                loginDto.getUsernameOrEmail(), loginDto.getPassword()));

        SecurityContextHolder.getContext().setAuthentication(authentication);

        String token = jwtTokenProvider.generateToken(authentication);

        return token;
    }
}

这是 AuthService 接口的实现。它包含一个方法 login(),用于处理应用程序的登录功能。loginDto 对象包含用户输入的用户名(username)和密码(password)。

该类的构造函数需要四个参数:JwtTokenProviderUserRepositoryPasswordEncoderAuthenticationManager

login() 方法中,authenticationManager 会尝试将用户的 loginDto 凭证传递给 UsernamePasswordAuthenticationToken,从而对用户进行身份认证。如果认证成功,将使用 jwtTokenProvider 对象生成一个 Token 并返回给调用者。

该 service 类使用 @Service 注解,表明它是 Spring 服务组件,可由 Spring Context 自动发现。

Controller 层 - 返回 JWT Token 的 REST API(登录)

创建 AuthController 类,并添加以下代码:

import lombok.AllArgsConstructor;
import net.javaguides.todo.dto.JWTAuthResponse;
import net.javaguides.todo.dto.LoginDto;
import net.javaguides.todo.service.AuthService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

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

    private AuthService authService;

    // Login REST API
    @PostMapping("/login")
    public ResponseEntity<JWTAuthResponse> authenticate(@RequestBody LoginDto loginDto){
        String token = authService.login(loginDto);

        JWTAuthResponse jwtAuthResponse = new JWTAuthResponse();
        jwtAuthResponse.setAccessToken(token);

        return ResponseEntity.ok(jwtAuthResponse);
    }
}

这段代码定义了一个用于用户身份认证的 REST API 端点。它通过 /api/auth/login URL 接收 POST 请求,请求体中的登录凭证是一个 JSON 对象。LoginDto 对象用于将 JSON 对象映射到 Java 对象。

AuthController 类有一个构造函数,用于接收 AuthService 的实例,AuthService 提供了身份认证逻辑。

authenticate 方法接收 LoginDto 对象作为参数,并调用 AuthServicelogin 方法来执行身份认证。如果验证成功,login 方法会返回一个 JWT Token。然后,该 Token 被封装在一个 JWTAuthResponse 对象中,并作为响应返回。

@PostMapping 注解将方法映射为 HTTP POST 方法。@RequestBody 注解表示请求体应映射到 LoginDto 对象。

SQL 脚本

在测试 Spring Security 和 JWT 之前,请确保使用以下 SQL 脚本将数据插入相应的表中:

INSERT INTO `users` VALUES
(1,'ramesh@gmail.com','ramesh','$2a$10$5PiyN0MsG0y886d8xWXtwuLXK0Y7zZwcN5xm82b4oDSVr7yF0O6em','ramesh'),
(2,'admin@gmail.com','admin','$2a$10$gqHrslMttQWSsDSVRTK1OehkkBiXsJ/a4z2OURU./dizwOQu5Lovu','admin');

INSERT INTO `roles` VALUES (1,'ROLE_ADMIN'),(2,'ROLE_USER');

INSERT INTO `users_roles` VALUES (2,1),(1,2);

Hibernate 会自动创建数据库表,因此无需手动创建。

使用 Postman 测试

测试返回 JWT Token 的登录 REST API,如下:

在 Postman 中测试登录 REST API

一切OK!


参考:https://www.javaguides.net/2023/05/spring-boot-spring-security-jwt-mysql.html