在 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 实体
创建 User
和 Role
JPA 实体,并在它们之间建立 多对多(MANY-to-MANY) 关系。
使用 JPA 注解在 User
和 Role
实体之间建立 多对多 关系。
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());
}
}
AuthenticationEntryPoint
由 ExceptionTranslationFilter
用来启动身份认证方案。它是一个入口点,用于检查用户是否已通过身份认证,如果用户已经认证,则登录该用户,否则抛出异常(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
,可确保每个请求只执行一次过滤器。 - 构造函数需要两个依赖:
JwtTokenProvider
和UserDetailsService
,它们是通过 Spring 的构造函数依赖注入机制注入的。 doFilterInternal
方法是 Filter 的主要逻辑。它使用getTokenFromRequest
方法从Authorization
Header 中提取 JWT Token,使用JwtTokenProvider
类验证 Token,并在SecurityContextHolder
中设置 Authentication 信息。getTokenFromRequest
方法会解析Authorization
Header,并返回 Token 部分。SecurityContextHolder
用于存储当前 request 的Authentication
信息。在这种情况下,Filter 会将UsernamePasswordAuthenticationToken
与该 Token 关联的UserDetails
和authorities
(授权)设置在一起。
CustomUserDetailsService
创建一个 Service,根据 name
或 email
从数据库中加载用户详细信息。
创建一个 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)
方法,用于查找给定 username
的 UserDetails
。
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)。
该类的构造函数需要四个参数:JwtTokenProvider
、UserRepository
、PasswordEncoder
和 AuthenticationManager
。
在 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
对象作为参数,并调用 AuthService
的 login
方法来执行身份认证。如果验证成功,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,如下:
一切OK!
参考:https://www.javaguides.net/2023/05/spring-boot-spring-security-jwt-mysql.html