Spring Boot 3 集成 Spring Security + JWT
准备工作
概述: 在本文中,我们将一步步学习如何使用 Spring Boot 3 和 Spring Security 来保护我们的应用程序。我们将从简单的入门开始,然后逐渐引入数据库,并最终使用 JWT 实现前后端分离。
引入依赖
这里主要用到了Mybatis-plus、hutool 、knife4j ,其他依赖可以直接勾选
xml
<properties>
<java.version>17</java.version>
<mybatisplus.version>3.5.9</mybatisplus.version>
<knife4j.version>4.5.0</knife4j.version>
<hutool.version>5.8.26</hutool.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</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.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</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-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<!-- MyBatis-Plus https://baomidou.com-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
</dependency>
<!--Knife4j https://doc.xiaominfo.com/-->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>${knife4j.version}</version>
</dependency>
<!-- Java工具类库 https://doc.hutool.cn -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-bom</artifactId>
<version>${mybatisplus.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
我这里使用的Spring boot版本为
3.3.5
,使用3.4.0
整合JWT过滤器时,打开swagger会报错:jakarta.servlet.ServletException: Handler dispatch failed: java.lang.NoSuchMethodError: 'void org.springframework.web.method.ControllerAdviceBean.<init>(java.lang.Object)
,说是版本兼容问题。暂时没有找到很好的解决方案,所以给Spring boot版本降至3.3.5
。
设计表结构
关于表结构内容我这里不详细的说了,各个表字段内容,可以拉一下代码,获取表结构sql脚本。关注公众号:"Harry技术",回复"jwt",即可获取到整个项目源码以及表结构。
sql
sys_config 系统配置表
sys_dept 部门表
sys_dict 字典表
sys_dict_data 字典数据表
sys_menu 菜单表
sys_role 角色表
sys_role_menu 角色菜单关系表
sys_user 用户表
sys_user_role 用户角色关系表
生成基本代码
白名单配置
因为我们这里引入knife4j ,关于knife4j 的相关配置可以参考《Spring Boot 3 整合Knife4j(OpenAPI3规范)》,我们需要将以下接口加入到白名单
yml
# 白名单列表
ignore-urls:
- /v3/api-docs/**
- /doc.html
- /swagger-resources/**
- /webjars/**
- /swagger-ui/**
- /swagger-ui.html
JWT配置
JWT(JSON Web Token)相关资料网络上非常多,可以自行搜索,简单点说JWT就是一种网络身份认证和信息交换格式。
Header
头部信息,主要声明了JWT的签名算法等信息Payload
载荷信息,主要承载了各种声明并传递明文数据Signature
签名,拥有该部分的JWT被称为JWS,也就是签了名的JWT,用于校验数据
整体结构是:
text
header.payload.signature
配置参数jwt密码、过期时间等
- yml 配置
yaml
# 安全配置
security:
jwt:
# JWT 秘钥
key: www.tech-harry.cn
# JWT 有效期(单位:秒)
ttl: 7200
# 白名单列表
ignore-urls:
- /v3/api-docs/**
- /doc.html
- /swagger-resources/**
- /webjars/**
- /swagger-ui/**
- /swagger-ui.html
- /auth/login
- 创建SecurityProperties
java
/**
* Security Properties
*
* @author harry
* @公众号 Harry技术
*/
@Data
@ConfigurationProperties(prefix = "security")
public class SecurityProperties {
/**
* 白名单 URL 集合
*/
private List<String> ignoreUrls;
/**
* JWT 配置
*/
private JwtProperty jwt;
/**
* JWT 配置
*/
@Data
public static class JwtProperty {
/**
* JWT 密钥
*/
private String key;
/**
* JWT 过期时间
*/
private Long ttl;
}
}
自定义未授权和未登录结果返回
在之前的案例中没有自定义未授权和未登录,直接在页面上显示错误信息,这样对于前端来说不是很好处理,我们将所有接口按照一定的格式返回,会方便前端交互处理。
-
未登录
java/** * 当未登录或者token失效访问接口时,自定义的返回结果 * * @author harry * @公众号 Harry技术 */ @Component public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setCharacterEncoding("UTF-8"); response.setContentType("application/json"); response.getWriter().println(JSONUtil.toJsonStr(R.unauthorized(authException.getMessage()))); response.getWriter().flush(); } }
-
未授权
java/** * 当访问接口没有权限时,自定义的返回结果 * * @author harry * @公众号 Harry技术 */ @Component public class RestfulAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException { response.setCharacterEncoding("UTF-8"); response.setContentType("application/json"); response.getWriter().println(JSONUtil.toJsonStr(R.forbidden(e.getMessage()))); response.getWriter().flush(); } }
创建JWT过滤器
这里直接使用了Hutool-jwt提供的JWTUtil工具类,主要包括:JWT创建、JWT解析、JWT验证。
java
/**
* JWT登录授权过滤器
*
* @author harry
* @公众号 Harry技术
*/
@Slf4j
public class JwtValidationFilter extends OncePerRequestFilter {
private final UserDetailsService userDetailsService;
// 密钥
private final byte[] secretKey;
public JwtValidationFilter(UserDetailsService userDetailsService, String secretKey) {
this.userDetailsService = userDetailsService;
this.secretKey = secretKey.getBytes();
}
@Override
protected void doFilterInternal(HttpServletRequest request, @Nonnull HttpServletResponse response, @Nonnull FilterChain chain) throws ServletException, IOException {
// 获取请求token
String token = request.getHeader(HttpHeaders.AUTHORIZATION);
try {
// 如果请求头中没有Authorization信息,或者Authorization以Bearer开头,则认为是匿名用户
if (StrUtil.isBlank(token) || !token.startsWith(SecurityConstants.JWT_TOKEN_PREFIX)) {
chain.doFilter(request, response);
return;
}
// 去除 Bearer 前缀
token = token.substring(SecurityConstants.JWT_TOKEN_PREFIX.length());
// 解析 Token
JWT jwt = JWTUtil.parseToken(token);
// 检查 Token 是否有效(验签 + 是否过期)
boolean isValidate = jwt.setKey(secretKey).validate(0);
if (!isValidate) {
log.error("JwtValidationFilter error: token is invalid");
throw new ApiException(ResultCode.UNAUTHORIZED);
}
JSONObject payloads = jwt.getPayloads();
String username = payloads.getStr(JWTPayload.SUBJECT);
SysUserDetails userDetails = (SysUserDetails) this.userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception e) {
log.error("JwtValidationFilter error: {}", e.getMessage());
SecurityContextHolder.clearContext();
throw new ApiException(ResultCode.UNAUTHORIZED);
}
// Token有效或无Token时继续执行过滤链
chain.doFilter(request, response);
}
}
改写SecurityConfig
关于Spring Boot 3 集成 Spring Security相关的知识点,可以参考文章:《Spring Boot 3 集成 Spring Security(1)认证》、《Spring Boot 3 集成 Spring Security(2)授权》、《Spring Boot 3 集成 Spring Security(3)数据管理》。
java
/**
* Spring Security 权限配置
*
* @author harry
* @公众号 Harry技术
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true) // 开启方法级别的权限控制
@RequiredArgsConstructor
public class SecurityConfig {
private final RestfulAccessDeniedHandler restfulAccessDeniedHandler;
private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
private final SecurityProperties securityProperties;
private final UserDetailsService userDetailsService;
@Bean
protected SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// 忽略的路径
http.authorizeHttpRequests(requestMatcherRegistry -> requestMatcherRegistry.requestMatchers(
securityProperties.getIgnoreUrls().toArray(new String[0])).permitAll()
.anyRequest().authenticated()
);
http
// 由于使用的是JWT,我们这里不需要csrf
.csrf(AbstractHttpConfigurer::disable)
// 禁用session
.sessionManagement(configurer ->
configurer
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// 添加自定义未授权和未登录结果返回
http.exceptionHandling(customizer ->
customizer
// 处理未授权
.accessDeniedHandler(restfulAccessDeniedHandler)
// 处理未登录
.authenticationEntryPoint(restAuthenticationEntryPoint));
// JWT 校验过滤器
http.addFilterBefore(new JwtValidationFilter(userDetailsService, securityProperties.getJwt().getKey()), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/**
* AuthenticationManager 手动注入
*
* @param authenticationConfiguration 认证配置
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
/**
* 强散列哈希加密实现
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
这里主要做了以下几点配置:
-
将不需要认证鉴权的接口加入白名单
-
由于使用的是JWT,我们这里不需要csrf、禁用session
-
添加自定义未授权和未登录结果返回
-
配置 JWT 校验过滤器
我们根据数据库中的用户信息加载用户,并将角色转换为 Spring Security 能识别的格式。我们写一个SysUserDetails
类来实现自定义Spring Security 用户对象。
java
/**
* 用户详情服务
*
* @author harry
* @公众号 Harry技术
*/
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final SysUserMapper sysUserMapper;
private final SysMenuMapper sysMenuMapper;
private final SysUserRoleMapper sysUserRoleMapper;
@Override
@Cacheable(value = CacheConstants.USER_DETAILS, key = "#username", unless = "#result == null ")
public SysUserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 获取登录用户信息
SysUser user = sysUserMapper.selectByUsername(username);
// 用户不存在
if (BeanUtil.isEmpty(user)) {
throw new ApiException(SysExceptionEnum.USER_NOT_EXIST);
}
Long userId = user.getUserId();
// 用户停用
if (StatusEnums.DISABLE.getKey().equals(user.getStatus())) {
throw new ApiException(SysExceptionEnum.USER_DISABLED);
}
// 获取角色
Set<String> roles = sysUserRoleMapper.listRoleKeyByUserId(userId);
// 获取数据范围标识
Integer dataScope = sysUserRoleMapper.getMaximumDataScope(roles);
Set<String> permissions = new HashSet<>();
// 如果 roles 包含 root 则拥有所有权限
if (roles.contains(CommonConstant.SUPER_ADMIN_ROOT)) {
permissions.add(CommonConstant.ALL_PERMISSION);
} else {
// 获取菜单权限标识
permissions = sysMenuMapper.getMenuPermission(userId);
// 过滤空字符串
permissions.remove("");
}
return new SysUserDetails(user, permissions, roles, username, dataScope);
}
}
这里使用了@Cacheable
结合redis做的缓存处理,关于缓存相关配置,可以参考文章《Spring Boot 3 整合Redis(1) 基础功能》、《Spring Boot 3 整合Redis(2)注解驱动缓存》。
登录验证
- 写一个登录接口/auth/login,返回 token、tokenType等信息
java
/**
* 登录相关
*
* @author harry
* @公众号 Harry技术
*/
@Slf4j
@RestController
@RequiredArgsConstructor
@Tag(name = "认证中心")
@RequestMapping("/auth")
public class LoginController {
private final SysUserService sysUserService;
@Operation(summary = "login 登录")
@PostMapping(value = "/login")
public R<LoginResult> login(@RequestBody SysUserLoginParam sysUserLoginParam) {
return R.success(sysUserService.login(sysUserLoginParam.getUsername(), sysUserLoginParam.getPassword()));
}
@Operation(summary = "info 获取当前用户信息")
@GetMapping(value = "/info")
public R<UserInfoResult> getInfo() {
UserInfoResult result = sysUserService.getInfo();
return R.success(result);
}
@Operation(summary = "logout 注销")
@PostMapping(value = "/logout")
public R logout(HttpServletRequest request) {
// 需要 将当前用户token 设置无效
SecurityContextHolder.clearContext();
return R.success();
}
}
-
LoginResult 对象
java/** * * @author harry * @公众号 Harry技术 */ @Data public class LoginResult { @Schema(description = "token") private String token; @Schema(description = "token 类型", example = "Bearer") private String tokenType; @Schema(description = "过期时间(单位:秒)", example = "604800") private Long expiration; @Schema(description = "刷新token") private String refreshToken; }
启动查看接口
访问http://localhost:8080/swagger-ui/index.html
或者http://localhost:8080/doc.html
未登录
当我们处于未登录状态时访问/auth/info
接口,直接返回了我们自定义的异常信息
登录
这里我们登录用户 harry/123456
,设定用户角色TEST
,菜单权限不给字典相关的操作。
看到接口成功返回token等信息,我们将token信息填写到 Authorize,作为全局配置。
这时,我们访问/auth/info
,可以看到当前登录的用户信息
我们访问字典相关的接口,如:/sys_dict/page
,返回了没有相关权限的信息
访问其他接口,如:/sys_dept/page
,可以看到数据正常返回。
总结
到这里,我们已经掌握了Spring Boot 3 整合 Security 的全过程。我们将从简单的入门开始,然后学习如何整合数据库,并最终使用 JWT 实现前后端分离。这些知识将帮助我们构建更安全、更可靠的应用程序。后续我们会深入了解在项目中用到的一些其他框架、工具。让我们一起开始吧!
示例源码:关注公众号"Harry技术",回复 jwt 获取源码地址。
文章推荐: