Sa-Token介绍
Sa-Token 是一个轻量级Java 权限认证框架,主要解决:登录认证、权限认证(RBAC,全称是基于角色的权限认证,主要是定义两个,身份和权限)、单点登录、OAuth2.0、分布式Session会话、微服务网关鉴权等一系列权限相关问题。
要理解 Sa-Token 的 Session 模型,区别于 Servlet 的 HttpSession,Sa-Token 维护了自己的 Session,共有3种Session 创建时机:
1、Account-session:指的是框架为每个 账号 id 分配的 Session。是分配给账号id的,而不是分配给指定客户端的,也就是说在 PC、APP 上登录的同一账号所得到的 Session 也是同一个,所以两端可以非常轻松的同步数据。
2、Token-session:指的是框架为每个 token 分配的 Session。不同的设备端,哪怕登录了同一账号,只要它们得到的 token 不一致,它们对应的 Token-session 就不一致,这就为我们不同端的独立数据读写提供了支持。比如实现“指定客户端超过两小时无操作就自动下线,如果两小时内有操作,就再续期两小时,直到新的两小时无操作”。
3、custom-session:指的是以一个特定的值作为 Sessionld,来分配的 Session。不依赖特定的账号id 或者 token,当成一个 Map 去使用即可,比如可以为一个团队的用户指定相同的 Sessionld,让一个团队最多 N 个用户同时在线等。
强烈建议仔细阅读学习一遍Sa-Token文档。
同端登录冲突检测
在多用户系统中,如电商平台、社交应用,用户的账户安全和系统的正常使用至关重要。如果同一个账户同时在多个设备上登录,可能由于之前在别的地方临时登录后忘记退出账号,导致数据泄露或账户被滥用等问题。此外,还有可能会导致数据不一致问题,例如重复提交订单、聊天记录不同步等。为了防止这些情况,我们的系统需要能够实时监控和检测同一账户在多个设备上的登录情况,在检测到冲突时,及时通知用户并采取相应的安全措施(如强制下线或警告)。
账号冲突检测策略常见业务上有3种冲突检测策略
1)单点登录模式同一时间,只允许同一账户在一个设备上登录。即每次新设备登录时,检测当前账户是否已经在其他设备上登录,若已登录则将其他设备强制下线。一般应用于企业内部系统或包含敏感数据的系统中。
2)多设备登录限制允许同一账户在多个设备上登录,但需限制设备数量(如最多两台)。即每次登录时,检测当前账户已登录设备数量,若超过限制,系统阻止登录或强制最早登录设备下线。这种模式常见于视频网站、买断制/订阅制软件。一个软件激活码只能同时在X台设备使用,也是类似的机制。
3)同设备类型限制允许同一账户在不同设备类型(如手机和 PC)上同时登录,但同一类型设备只能登录一个。例如,用户可同时在电脑和 iPad 上使用我们的平台,但不能在两台手机上登录在线。(QQ 就是这样)要想实现这种策略,需要记录账户登录设备的类型,每次登录时检查是否有同类设备已登录,如果有则强制下线相同设备类型的旧登录。
这种策略常应用于一些需兼顾多端体验的应用。比如刷题平台用户,经常有 iPad 和电脑端同时刷题的需求,因此我们采用第3种策略 同设备类型限制(同端互斥登录)。
实现思路如何实现同端互斥登录呢?
1.用户登录时获取当前设备信息(通过 User-Agent 获取)2.将用户登录信息与设备信息一起保存(本地或三方缓存中)3.用户登录时判断同设备是否已经登录(本地或缓存中是否已存在)4.如果检测到冲突,可以直接顶号(将前一个设备下线,也就是移除登录态)
其实自主实现这个需求也并不难,但涉及到登录这样的核心业务场景,经验不足的情况下,更建议使用一些成熟的第三方框架。比如轻量级 Java 权限认证框架 Sa-Token 内置了 同端互斥登录功能,可以更快更稳地实现。
首先我们引入Sa-Token的依赖:
<!-- Sa-Token 权限认证 --> <dependency> <groupId>cn.dev33</groupId> <artifactId>sa-token-spring-boot-starter</artifactId> <version>1.39.0</version> </dependency>接着我们编写application.yml配置,定制sa-token框架行为,如,登录态过期时间、不活跃自动下线等,重点关注 is-concurrent ,需要设置为 false,这样才能实现同端冲突下线:
# Sa-Token 配置 sa-token: # token 名称(同时也是 cookie 名称) token-name: zhimianguan # token 有效期(单位:秒) 默认30天,-1 代表永久有效 timeout: 2592000 # token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结 active-timeout: -1 # 是否允许同一账号多地同时登录(为 true 时允许一起登录, 为 false 时新登录挤掉旧登录) is-concurrent: false # 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token) is-share: true # token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik) token-style: uuid # 是否输出操作日志 is-log: true因为我们现在的项目里是使用 AOP +自定义权限校验注解来实现的登录认证和权限认证,所以我们使用注解鉴权的模式来实现同端登录互斥。
在根包下新建一个satoken包,然后写一个satoken全局拦截器:
/** * 配置 Sa-Token 全局拦截器(为了支持注解鉴权模式) */ @Configuration public class SaTokenConfigure implements WebMvcConfigurer { // 注册 Sa-Token 拦截器,打开注解式鉴权功能 @Override public void addInterceptors(InterceptorRegistry registry) { // 注册 Sa-Token 拦截器,打开注解式鉴权功能 registry.addInterceptor(new SaInterceptor()).addPathPatterns("/**"); } }有了sa-token鉴权拦截器,如果项目原本的aop包下有自己写AOP定义的AuthInterceptor权限拦截器,可以把里面的两个注解注释掉,就不会被springboot扫描执行了,然后可以用sa-token的鉴权拦截器:
//@Aspect //@Component接着我们就需要去定义我们有哪些角色有哪些权限需要去让sa-token自动校验,需要在satoken包下新建一个实现类去实现StpInterface接口:
/** * 自定义权限验证接口StpInterface实现类扩展 */ @Component public class StpInterfaceImpl implements StpInterface { /** * 返回一个账号所拥有的权限码集合(目前没用) */ @Override public List<String> getPermissionList(Object loginId, String s) { return new ArrayList<>(); } /** * 返回一个账号所拥有的角色标识集合(权限与角色可分开校验) */ @Override public List<String> getRoleList(Object loginId, String s) { //从当前登录用户信息中获取角色 User user = (User)StpUtil.getSessionByLoginId(loginId).get(USER_LOGIN_STATE); return Collections.singletonList(user.getUserRole()); } }接着我们要实现同端登录冲突那肯定需要新建一个设备信息获取工具类,在satoken包下新建一个DeviceUtils类:
/** * 设备工具类 */ public class DeviceUtils { /** * 根据请求获取设备信息 **/ public static String getRequestDevice(HttpServletRequest request) { String userAgentStr = request.getHeader(Header.USER_AGENT.toString()); // 使用 Hutool 解析 UserAgent UserAgent userAgent = UserAgentUtil.parse(userAgentStr); ThrowUtils.throwIf(userAgent == null, ErrorCode.OPERATION_ERROR, "非法请求"); // 默认值是 PC String device = "pc"; // 是否为小程序 if (isMiniProgram(userAgentStr)) { device = "miniProgram"; } else if (isPad(userAgentStr)) { // 是否为 Pad device = "pad"; } else if (userAgent.isMobile()) { // 是否为手机 device = "mobile"; } return device; } /** * 判断是否是小程序 * 一般通过 User-Agent 字符串中的 "MicroMessenger" 来判断是否是微信小程序 **/ private static boolean isMiniProgram(String userAgentStr) { // 判断 User-Agent 是否包含 "MicroMessenger" 表示是微信环境 return StrUtil.containsIgnoreCase(userAgentStr, "MicroMessenger") && StrUtil.containsIgnoreCase(userAgentStr, "MiniProgram"); } /** * 判断是否为平板设备 * 支持 iOS(如 iPad)和 Android 平板的检测 **/ private static boolean isPad(String userAgentStr) { // 检查 iPad 的 User-Agent 标志 boolean isIpad = StrUtil.containsIgnoreCase(userAgentStr, "iPad"); // 检查 Android 平板(包含 "Android" 且不包含 "Mobile") boolean isAndroidTablet = StrUtil.containsIgnoreCase(userAgentStr, "Android") && !StrUtil.containsIgnoreCase(userAgentStr, "Mobile"); // 如果是 iPad 或 Android 平板,则返回 true return isIpad || isAndroidTablet; } }然后来修改一下登录接口,运用同端登录冲突,在UserServiceImpl的userLogin中给sa-token设置登录态,保存登录用户信息:
// 3. 记录用户的登录态 // request.getSession().setAttribute(USER_LOGIN_STATE, user); StpUtil.login(user.getId(), DeviceUtils.getRequestDevice(request)); StpUtil.getSession().set(USER_LOGIN_STATE, user);补充说明:user.getId()就是登录成功的用户 id,可以在 StpInterfaceImpl 中通过方法参数 loginld 获取到。注意, Stputil.getsession()是 SaSession ,它是获取当前账号id的Account-Session(必须登录后才能调用)与 HttpSession 没任何关系, SaSession 是 Sa-Token 提供的会话中专业的数据缓存组件,通过 Sasession 我们可以很方便的缓存一些高频读写数据(比如登录用户信息),提高程序性能。
接着我们来修改获取用户信息的逻辑,将UserServiceImpl的getLoginUser的原本通过request.getSession()获取登录用户的id改为从Sa-Token中获取(这边我们采用是通过Sa-Token先获取id,然后再根据id去数据库查到用户的信息,这样保证了数据的一致性,如果用户信息修改不频繁的话,可以不查数据库,你还可以直接从Sa-Token的Session中获取之前保存的用户登录态):
/** * 获取当前登录用户 * * @param request * @return */ @Override public User getLoginUser(HttpServletRequest request) { // 先判断是否已登录 Object loginUserId = StpUtil.getLoginIdDefaultNull(); if (loginUserId == null) { throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR); } // 从数据库查询(追求性能的话可以注释,直接走缓存) User currentUser = this.getById((String) loginUserId); if (currentUser == null) { throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR); } return currentUser; }然后原本从Servlet Session中获取的登录态的地方都要修改为从Sa-Token中获取,UserServiceImpl中的isAdmin方法:
/** * 是否为管理员 * * @param request * @return */ public boolean isAdmin(HttpServletRequest request) { // 仅管理员可查询 // 基于 Sa-Token 改造 Object userObj = StpUtil.getSession().get(USER_LOGIN_STATE); // Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE); User user = (User) userObj; return isAdmin(user); }如果还有用户注销功能也修改一下:
/** * 用户注销 * * @param request */ @Override public boolean userLogout(HttpServletRequest request) { StpUtil.checkLogin(); // 移除登录态 StpUtil.logout(); return true; }接着就是前面说到的如果有自己用AOP定义,我们需要将controller里的所有原本使用@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)权限注解修改为Sa-Token的权限注解@SaCheckRole(UserConstant.ADMIN_ROLE),使用ctrl+shift+R快捷键全局替换。
@SaCheckRole(UserConstant.ADMIN_ROLE)最后我们来测试一下看看是否成功实现同端登录冲突。
经过测试发现,如果不做任何处理,接口返回的无权限和未登录的报错如下:
{ "code": 50000, "data": null, "message": "系统错误" }因此我们还可以给Sa-Token增加一个全局异常处理器,使效果更直观,而不是接口报错返回的是系统错误,在exception包下的GlobalExceptionHandler类里添加以下异常处理:
//Sa-Token全局异常处理器 @ExceptionHandler(NotRoleException.class) public BaseResponse<?> notRoleExceptionHandler(RuntimeException e) { log.error("NotRoleException", e); return ResultUtils.error(ErrorCode.NO_AUTH_ERROR, "无权限"); } @ExceptionHandler(NotLoginException.class) public BaseResponse<?> notLoginExceptionHandler(RuntimeException e) { log.error("NotLoginException", e); return ResultUtils.error(ErrorCode.NOT_LOGIN_ERROR, "未登录"); }经过测试发现,我们项目重启后会丢失之前的token,因为它默认是将数据保存在内存中,而且目前无法在分布式环境中共享数据,所以要解决这两个问题我们可以将Sa-Token集成Redis,可以参考Sa-Token官方文档来集成Redis,集成redis之后不需要我们手动保存数据,Sa-Token框架会自动帮我们保存,步骤如下:
首先引入依赖:
<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) --> <dependency> <groupId>cn.dev33</groupId> <artifactId>sa-token-redis-jackson</artifactId> <version>1.39.0</version> </dependency> <!-- 提供Redis连接池 --> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>然后在pom.xml中添加redis配置:
# Redis 配置 redis: # Redis数据库索引(默认为0) database: 1 # Redis服务器地址 host: 127.0.0.1 # Redis服务器连接端口 port: 6379 # Redis服务器连接密码(默认为空) # password: # 连接超时时间 timeout: 10s lettuce: pool: # 连接池最大连接数 max-active: 200 # 连接池最大阻塞等待时间(使用负值表示没有限制) max-wait: -1ms # 连接池中的最大空闲连接 max-idle: 10 # 连接池中的最小空闲连接 min-idle: 0我们还可以补充实现同端登录冲突时,被踢下线的用户前端会收到通知实现思路:
1、自定义“登录冲突被踢下线"异常,比如状态码 40110
2、用户在一个设备A登录后,如果在另一个设备B登录时,系统判断该用户已经在其他设备上登录过了,会移除设备A的登录态,并且在 Redis 里记录一条设备A的 sessionld 的冲突标识,之后用户在设备 A 请求任何一个接口(除登录接口),都会返回一个冲突的错误响应消息:
{ "code": 40110, "message": "用户已经在其他设备登录" }3、然后就可以移除在 Redis 里的冲突标识。之后请求任何接口,都能正常响应,如果需要登录,会和之前一样返回未登录。
4、前端全局响应拦截器中捕获该异常,并弹出消息通知框即可。
相比轮询或者 WebSocket,这种懒加载惰性请求的方案更轻量,要是用户一天都不用电脑就不用每隔一段时间就轮询浪费性能,能够减少服务器的压力。
我们还需要去学习了解:单点登录和 OAuth2
可以直接阅读 Sa-Token 的官方文档学习,介绍地非常详细:SSO 单点登录:https://sa-token.cc/doc.html#/sso/readmeOAuth2:https://sa-token.cc/doc.html#/oauth2/readme