目录
后端注册拦截器
实现对 WebMvcConfigurer 接口的类实现
静态变量
方法重写 注册 Spring Framework拦截器
Sa-Token中SaServletFilter拦截器
思考 为什么使用两个拦截器
1. Spring Framework 拦截器
2. SaServletFilter
为什么要注册两个拦截器?
总结
完整代码
后端注册权限验证接口扩展
实现 Satoken 的 StpInterface 接口
获取用户和权限码
在 Dao 层实现
第一张表是 菜单表
第二张表是 用户表
多表联查 SQL 语句
遍历获取权限 getPermissionList 方法
获取角色 getRoleList 方法
主要逻辑
权限与角色的关系
数据库查询
SaSession 和缓存
完整代码
后端自定义侦听器
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
拦截器
根据文档里面的写法
@Beanpublic 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 {@Autowiredprivate 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;@Overridepublic void addInterceptors(InterceptorRegistry registry) {// 注册分页拦截器registry.addInterceptor(new PageableInterceptor());// 注册Redis限流器registry.addInterceptor(accessLimitInterceptor);// 注册 Sa-Token 的注解拦截器,打开注解式鉴权功能registry.addInterceptor(new SaInterceptor()).addPathPatterns("/**");}@Beanpublic 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.permsFROM t_menu mINNER JOIN t_role_menu rm ON m.id = rm.menu_idWHERE 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 {@Autowiredprivate MenuMapper menuMapper;@Autowiredprivate RoleMapper roleMapper;/*** 返回一个账号所拥有的权限码集合** @param loginId 登录用户id* @param loginType 登录账号类型* @return 权限集合*/@Overridepublic 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 角色集合*/@Overridepublic 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
(用于解析用户的操作系统和浏览器信息)等工具类。
完整代码
/*** 每次登录时触发*/@Overridepublic 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和osMap<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);// 用户在线信息存入tokenSessionSaSession tokenSession = StpUtil.getTokenSessionByToken(tokenValue);tokenSession.set(ONLINE_USER, onlineUserResp);}