目录
[实现对 WebMvcConfigurer 接口的类实现](#实现对 WebMvcConfigurer 接口的类实现)
[方法重写 注册 Spring Framework拦截器](#方法重写 注册 Spring Framework拦截器)
[思考 为什么使用两个拦截器](#思考 为什么使用两个拦截器)
[1. Spring Framework 拦截器](#1. Spring Framework 拦截器)
[2. SaServletFilter](#2. SaServletFilter)
[实现 Satoken 的 StpInterface 接口](#实现 Satoken 的 StpInterface 接口)
[在 Dao 层实现](#在 Dao 层实现)
[第一张表是 菜单表](#第一张表是 菜单表)
[第二张表是 用户表](#第二张表是 用户表)
[多表联查 SQL 语句](#多表联查 SQL 语句)
[遍历获取权限 getPermissionList 方法](#遍历获取权限 getPermissionList 方法)
[获取角色 getRoleList 方法](#获取角色 getRoleList 方法)
[SaSession 和缓存](#SaSession 和缓存)
[doLogin --- 用户登录时触发](#doLogin — 用户登录时触发)
后端注册拦截器
实现对 WebMvcConfigurer 接口的类实现
静态变量
一个是不需要鉴权的网址
一个是超时过期时间
方法重写 注册 Spring Framework拦截器
这段代码的目的是注册多个拦截器,在 Spring 应用中统一处理:
- 分页:处理分页相关的逻辑。
- 限流:通过 Redis 实现请求频率控制,防止滥用。
- 权限鉴权:使用 Sa-Token 框架进行请求的权限验证。
所有这些拦截器会在请求到达控制器之前执行,确保全局的功能逻辑一致性(如分页、限流和权限检查)。
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册分页拦截器
registry.addInterceptor(new PageableInterceptor());
// 注册Redis限流器
registry.addInterceptor(accessLimitInterceptor);
// 注册 Sa-Token 的注解拦截器,打开注解式鉴权功能
registry.addInterceptor(new SaInterceptor()).addPathPatterns("/**");
}
Sa-Token
中SaServletFilter
拦截器
根据文档里面的写法
@Bean
public SaServletFilter getSaServletFilter() {
return new SaServletFilter()
// 拦截路径
.addInclude("/**")
// 放开路径
.addExclude(EXCLUDE_PATH_PATTERNS)
// 前置函数:在每次认证函数之前执行
.setBeforeAuth(obj -> {
SaHolder.getResponse()
// 允许指定域访问跨域资源
.setHeader("Access-Control-Allow-Origin", "*")
// 允许所有请求方式
.setHeader("Access-Control-Allow-Methods", "*")
// 有效时间
.setHeader("Access-Control-Max-Age", "3600")
// 允许的header参数
.setHeader("Access-Control-Allow-Headers", "*");
// 如果是预检请求,则立即返回到前端
SaRouter.match(SaHttpMethod.OPTIONS)
.free(r -> System.out.println("--------OPTIONS预检请求,不做处理"))
.back();
})
// 认证函数: 每次请求执行
.setAuth(obj -> {
// 检查是否登录
SaRouter.match("/admin/**").check(r -> StpUtil.checkLogin());
// 刷新token有效期
if (StpUtil.getTokenTimeout() < timeout) {
StpUtil.renewTimeout(1800);
}
// 输出 API 请求日志,方便调试代码
SaManager.getLog().debug("请求path={} 提交token={}", SaHolder.getRequest().getRequestPath(), StpUtil.getTokenValue());
})
// 异常处理函数:每次认证函数发生异常时执行此函数
.setError(e -> {
// 设置响应头
SaHolder.getResponse().setHeader("Content-Type", "application/json;charset=UTF-8");
if (e instanceof NotLoginException) {
// todo 确实是这边有问题
e.printStackTrace();
return JSONUtil.toJsonStr(Result.fail(UNAUTHORIZED.getCode(), UNAUTHORIZED.getMsg()));
}
// TODO 服务器后端在这里无法捕获异常,仅仅将异常信息传给了前端
e.printStackTrace();
return SaResult.error(e.getMessage());
});
}
思考 为什么使用两个拦截器
1. Spring Framework 拦截器
Spring 框架中的拦截器通常是通过实现 HandlerInterceptor
接口,或通过继承 WebMvcConfigurer
类中的 addInterceptors
方法来注册的。这类拦截器一般用于以下目的:
- 日志记录:记录请求的日志。
- 权限检查:用于访问控制,判断用户是否有权限访问某些资源。
- 性能监控:统计接口响应时间等。
- 请求处理:在请求进入控制器前对请求进行预处理,或在请求完成后进行后处理。
Spring 的拦截器是基于 HandlerInterceptor
接口的,它是为 Spring MVC 控制器定制的,具有以下生命周期:
- preHandle:在请求到达控制器之前执行。
- postHandle:在控制器方法执行后,渲染视图之前执行。
- afterCompletion:视图渲染完毕后执行,通常用于清理资源。
在这段代码中,Spring 的拦截器被注册为:
- 分页拦截器 :用于处理分页参数(如
page
和size
),确保分页逻辑的一致性。 - 限流拦截器:用于限制 API 请求的频率,防止过于频繁的请求对服务器造成压力。
- 权限拦截器:用于进行权限检查,确保用户请求的资源需要相应的权限。
2. SaServletFilter
SaServletFilter
是 Sa-Token 框架提供的一个过滤器,用于处理认证和权限管理。它作为一个 Servlet Filter
被引入到 Spring Web 应用中,并在请求进入控制器之前执行。SaServletFilter
的职责通常包括:
- 用户认证:检查请求是否携带有效的 token,判断用户是否登录。
- 跨域处理:配置跨域请求的响应头,确保前端能够正常访问接口。
- 权限控制:根据不同的 URL 路径,检查用户是否具有访问权限。
- 异常处理:在认证或权限检查失败时,返回统一的错误信息。
SaServletFilter
是一个全局过滤器,会拦截所有请求,处理权限相关的逻辑。它的作用是在用户访问接口时进行认证、权限检查、跨域处理等。
为什么要注册两个拦截器?
- 职责分离:
-
- Spring 拦截器 主要用于通用功能,如分页、限流、日志等,这些是应用中与业务逻辑和请求处理相关的通用功能。
- SaServletFilter 主要负责安全相关的功能,如认证和权限检查。它是 Sa-Token 提供的专用过滤器,能够帮助应用实现基于 token 的权限控制。
- 功能互补:
-
SaServletFilter
是为了处理与用户认证、权限相关的安全需求,而 Spring 拦截器通常用于通用功能(如分页、限流)。这两者的作用并不冲突,反而可以互补,Spring 拦截器可以集中处理一些公共逻辑,SaServletFilter
则专注于用户认证和权限控制。
- 实现细粒度控制:
-
- 在复杂的应用中,你可能需要对某些路径进行分页和限流控制,但对于其他路径,你需要确保严格的权限检查。
SaServletFilter
提供了灵活的权限认证机制,而 Spring 的拦截器可以细分不同的逻辑(如分页、限流等),实现更精细的控制。
- 在复杂的应用中,你可能需要对某些路径进行分页和限流控制,但对于其他路径,你需要确保严格的权限检查。
- 统一认证与权限管理:
-
SaServletFilter
通过拦截所有请求并统一处理认证、跨域、权限等,确保每个请求都能经过安全检查。Spring 拦截器负责处理请求中的其他逻辑(如日志记录、分页等),这样可以将应用中的各个功能模块进行解耦,并且保证权限检查与认证操作的统一性。
总结
- Spring Framework 拦截器:主要负责处理与请求相关的公共逻辑,如分页、限流、日志记录等。
- SaServletFilter:主要负责安全相关的功能,执行认证、权限控制、跨域处理等操作。
通过同时注册这两个拦截器,应用能够既保持业务逻辑的清晰和分离,又能确保安全性(认证与权限管理)得到充分保障。两者各司其职,共同为应用提供完整的功能支持。
完整代码
package com.ican.satoken;
import cn.dev33.satoken.SaManager;
import cn.dev33.satoken.context.SaHolder;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.filter.SaServletFilter;
import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.router.SaHttpMethod;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import cn.hutool.json.JSONUtil;
import com.ican.interceptor.AccessLimitInterceptor;
import com.ican.interceptor.PageableInterceptor;
import com.ican.model.vo.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import static com.ican.enums.StatusCodeEnum.UNAUTHORIZED;
/**
* SaToken配置
*
* @author Dduo
* @date 2024/11/28 22:12
**/
@Slf4j
@Component
public class SaTokenConfig implements WebMvcConfigurer {
@Autowired
private AccessLimitInterceptor accessLimitInterceptor;
private final String[] EXCLUDE_PATH_PATTERNS = {
"/swagger-resources",
"/webjars/**",
"/v2/api-docs",
"/doc.html",
"/favicon.ico",
"/login",
"/oauth/*",
};
private final long timeout = 600;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册分页拦截器
registry.addInterceptor(new PageableInterceptor());
// 注册Redis限流器
registry.addInterceptor(accessLimitInterceptor);
// 注册 Sa-Token 的注解拦截器,打开注解式鉴权功能
registry.addInterceptor(new SaInterceptor()).addPathPatterns("/**");
}
@Bean
public SaServletFilter getSaServletFilter() {
return new SaServletFilter()
// 拦截路径
.addInclude("/**")
// 放开路径
.addExclude(EXCLUDE_PATH_PATTERNS)
// 前置函数:在每次认证函数之前执行
.setBeforeAuth(obj -> {
SaHolder.getResponse()
// 允许指定域访问跨域资源
.setHeader("Access-Control-Allow-Origin", "*")
// 允许所有请求方式
.setHeader("Access-Control-Allow-Methods", "*")
// 有效时间
.setHeader("Access-Control-Max-Age", "3600")
// 允许的header参数
.setHeader("Access-Control-Allow-Headers", "*");
// 如果是预检请求,则立即返回到前端
SaRouter.match(SaHttpMethod.OPTIONS)
.free(r -> System.out.println("--------OPTIONS预检请求,不做处理"))
.back();
})
// 认证函数: 每次请求执行
.setAuth(obj -> {
// 检查是否登录
SaRouter.match("/admin/**").check(r -> StpUtil.checkLogin());
// 刷新token有效期
if (StpUtil.getTokenTimeout() < timeout) {
StpUtil.renewTimeout(1800);
}
// 输出 API 请求日志,方便调试代码
SaManager.getLog().debug("请求path={} 提交token={}", SaHolder.getRequest().getRequestPath(), StpUtil.getTokenValue());
})
// 异常处理函数:每次认证函数发生异常时执行此函数
.setError(e -> {
// 设置响应头
SaHolder.getResponse().setHeader("Content-Type", "application/json;charset=UTF-8");
if (e instanceof NotLoginException) {
// todo 确实是这边有问题
e.printStackTrace();
return JSONUtil.toJsonStr(Result.fail(UNAUTHORIZED.getCode(), UNAUTHORIZED.getMsg()));
}
// TODO 服务器后端在这里无法捕获异常,仅仅将异常信息传给了前端
e.printStackTrace();
return SaResult.error(e.getMessage());
});
}
}
后端注册权限验证接口扩展
实现 Satoken 的 StpInterface 接口
获取用户和权限码
根据文档里面的内容
每个用户 id 都对应一系列的权限码
每个账号都会拥有一组权限码集合,框架来校验这个集合中是否包含指定的权限码。
我们将权限码存到数据库里面
在 Dao 层实现
第一张表是 菜单表
第二张表是 用户表
多表联查 SQL 语句
<select id="selectPermissionByRoleId" resultType="java.lang.String">
SELECT DISTINCT m.perms
FROM t_menu m
INNER JOIN t_role_menu rm ON m.id = rm.menu_id
WHERE rm.role_id = #{roleId}
AND m.is_disable = 0
</select>
遍历获取权限 getPermissionList
方法
/**
* 返回一个账号所拥有的权限码集合
*
* @param loginId 登录用户id
* @param loginType 登录账号类型
* @return 权限集合
*/
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
// 声明权限码集合
List<String> permissionList = new ArrayList<>();
// 遍历角色列表,查询拥有的权限码
for (String roleId : getRoleList(loginId, loginType)) {
SaSession roleSession = SaSessionCustomUtil.getSessionById("role-" + roleId);
List<String> list = roleSession.get("Permission_List", () -> menuMapper.selectPermissionByRoleId(roleId));
permissionList.addAll(list);
}
// 返回权限码集合
return permissionList;
}
- 角色与权限的查询 :通过
getRoleList
获取用户角色,再通过getPermissionList
获取每个角色的权限。 - 会话缓存 :使用
SaSession
缓存角色和权限信息,减少对数据库的查询。 - 自定义权限与角色管理 :通过扩展
StpInterface
接口,灵活地实现了用户角色和权限的管理,适应应用中的业务需求。
获取角色 getRoleList
方法
@Override
public List<String> getRoleList(Object loginId, String loginType) {
SaSession session = StpUtil.getSessionByLoginId(loginId);
return session.get("Role_List", () -> roleMapper.selectRoleListByUserId(loginId));
}
- 功能 :返回一个账号(
loginId
)所拥有的所有角色。 - 实现步骤:
-
- 通过
StpUtil.getSessionByLoginId(loginId)
获取当前登录用户的会话。 - 从会话中获取角色列表。如果会话中没有角色信息,则通过
roleMapper.selectRoleListByUserId(loginId)
查询数据库获取。 - 返回角色列表。
- 通过
主要逻辑
- getRoleList 方法用于获取用户的角色列表。每个用户可以有多个角色,角色是权限的载体。
- getPermissionList 方法根据用户的角色列表,查询每个角色对应的权限。权限是基于角色的,角色是用户的身份标识。权限是用户访问特定资源的授权标识。
权限与角色的关系
- 在这个实现中,用户是通过角色来管理权限的。每个用户有一组角色,而每个角色又有一组权限。这种关系是典型的 角色权限控制(RBAC) 模式。
getRoleList
获取用户的角色列表,而getPermissionList
则是基于角色来获取相应的权限。
数据库查询
menuMapper.selectPermissionByRoleId(roleId)
:根据角色 ID 查询该角色所拥有的权限。权限通常与菜单或 API 请求相关。roleMapper.selectRoleListByUserId(loginId)
:根据用户 ID 查询该用户所拥有的角色。
SaSession
和缓存
SaSession
在这个实现中被用于缓存角色和权限信息。这样,避免每次请求都进行数据库查询。使用会话可以提高性能,避免重复查询数据库。
完整代码
package com.ican.satoken;
import cn.dev33.satoken.session.SaSession;
import cn.dev33.satoken.session.SaSessionCustomUtil;
import cn.dev33.satoken.stp.StpInterface;
import cn.dev33.satoken.stp.StpUtil;
import com.ican.mapper.MenuMapper;
import com.ican.mapper.RoleMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* 自定义权限验证接口扩展
*
* @author Dduo
*/
@Component
public class StpInterfaceImpl implements StpInterface {
@Autowired
private MenuMapper menuMapper;
@Autowired
private RoleMapper roleMapper;
/**
* 返回一个账号所拥有的权限码集合
*
* @param loginId 登录用户id
* @param loginType 登录账号类型
* @return 权限集合
*/
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
// 声明权限码集合
List<String> permissionList = new ArrayList<>();
// 遍历角色列表,查询拥有的权限码
for (String roleId : getRoleList(loginId, loginType)) {
SaSession roleSession = SaSessionCustomUtil.getSessionById("role-" + roleId);
List<String> list = roleSession.get("Permission_List", () -> menuMapper.selectPermissionByRoleId(roleId));
permissionList.addAll(list);
}
// 返回权限码集合
return permissionList;
}
/**
* 返回一个账号所拥有的可用角色标识集合
*
* @param loginId 登录用户id
* @param loginType 登录账号类型
* @return 角色集合
*/
@Override
public List<String> getRoleList(Object loginId, String loginType) {
SaSession session = StpUtil.getSessionByLoginId(loginId);
return session.get("Role_List", () -> roleMapper.selectRoleListByUserId(loginId));
}
}
后端自定义侦听器
doLogin
--- 用户登录时触发
- 功能:每当用户成功登录时触发此方法。
- 主要步骤:
- 查询用户信息,包括头像和昵称。
- 解析用户的浏览器和操作系统信息(通过
UserAgentUtils.parseOsAndBrowser
)。 - 获取登录时的 IP 地址,并查询该 IP 的来源地(如城市名)。
- 获取当前的登录时间。
- 创建一个
OnlineUserResp
对象,包含用户的基本信息、登录 IP、操作系统和浏览器等信息,并将其保存到SaSession
中。 - 更新数据库中的用户信息(如 IP 地址、登录时间等)。
- 作用:此方法不仅处理了用户登录,还将用户的登录信息(如 IP 地址、设备信息等)存储到会话中,供后续使用。同时更新了数据库中的用户信息,以便后续管理。
总结
- 功能 :
MySaTokenListener
实现了SaTokenListener
接口,并提供了用户登录、注销、踢下线等事件的自定义处理。
-
- 在登录时,记录用户的登录信息,包括设备、IP 地址、地理位置等,并将这些信息存储到 token 会话中。
- 在注销时,清除 token 会话中的用户信息。
- 扩展性:其他事件(如被踢下线、二级认证等)目前没有具体实现,但可以根据业务需求添加逻辑,比如发送通知、更新状态等。
- 依赖 :该实现依赖于
UserMapper
(用于查询和更新用户信息)、IpUtils
(用于获取 IP 地址的来源)、UserAgentUtils
(用于解析用户的操作系统和浏览器信息)等工具类。
完整代码
/**
* 每次登录时触发
*/
@Override
public void doLogin(String loginType, Object loginId, String tokenValue, SaLoginModel loginModel) {
// 查询用户昵称
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.select(User::getAvatar, User::getNickname)
.eq(User::getId, loginId));
// 解析browser和os
Map<String, String> userAgentMap = UserAgentUtils.parseOsAndBrowser(request.getHeader("User-Agent"));
// 获取登录ip地址
String ipAddress = ServletUtil.getClientIP(request);
// 获取登录地址
String ipSource = IpUtils.getIpSource(ipAddress);
// 获取登录时间
LocalDateTime loginTime = LocalDateTime.now(ZoneId.of(SHANGHAI.getZone()));
OnlineUserResp onlineUserResp = OnlineUserResp.builder()
.id((Integer) loginId)
.token(tokenValue)
.avatar(user.getAvatar())
.nickname(user.getNickname())
.ipAddress(ipAddress)
.ipSource(ipSource)
.os(userAgentMap.get("os"))
.browser(userAgentMap.get("browser"))
.loginTime(loginTime)
.build();
// 更新用户登录信息
User newUser = User.builder()
.id((Integer) loginId)
.ipAddress(ipAddress)
.ipSource(ipSource)
.loginTime(loginTime)
.build();
userMapper.updateById(newUser);
// 用户在线信息存入tokenSession
SaSession tokenSession = StpUtil.getTokenSessionByToken(tokenValue);
tokenSession.set(ONLINE_USER, onlineUserResp);
}