V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
爱意满满的作品展示区。
konglizhi3362
V2EX  ›  分享创造

(权限认证新轮子)SpringBoot 项目使用 Sa-Token 完成登录认证

  •  
  •   konglizhi3362 · 2023-05-09 12:24:21 +08:00 · 2489 次点击
    这是一个创建于 569 天前的主题,其中的信息可能已经有所发展或是发生改变。

    SpringBoot 项目使用 Sa-Token 完成登录认证

    一、设计思路

    对于一些登录之后才能访问的接口(例如:查询我的账号资料),我们通常的做法是增加一层接口校验:

    • 如果校验通过,则:正常返回数据。
    • 如果校验未通过,则:抛出异常,告知其需要先进行登录。

    那么,判断会话是否登录的依据是什么?我们先来简单分析一下登录访问流程:

    1. 用户提交 name + password 参数,调用登录接口。
    2. 登录成功,返回这个用户的 Token 会话凭证。
    3. 用户后续的每次请求,都携带上这个 Token 。
    4. 服务器根据 Token 判断此会话是否登录成功。

    所谓登录认证,指的就是服务器校验账号密码,为用户颁发 Token 会话凭证的过程,这个 Token 也是我们后续判断会话是否登录的关键所在。

    动态图演示:

    登录认证

    接下来,我们将介绍在 SpringBoot 中如何使用 Sa-Token 完成登录认证操作。

    Sa-Token 是一个 java 权限认证框架,主要解决登录认证、权限认证、单点登录、OAuth2 、微服务网关鉴权 等一系列权限相关问题。 Gitee 开源地址:https://gitee.com/dromara/sa-token

    首先在项目中引入 Sa-Token 依赖:

    <!-- Sa-Token 权限认证 -->
    <dependency>
        <groupId>cn.dev33</groupId>
        <artifactId>sa-token-spring-boot-starter</artifactId>
        <version>1.34.0</version>
    </dependency>
    

    注:如果你使用的是 SpringBoot 3.x,只需要将 sa-token-spring-boot-starter 修改为 sa-token-spring-boot3-starter 即可。

    二、登录与注销

    根据以上思路,我们需要一个会话登录的函数:

    // 会话登录:参数填写要登录的账号 id ,建议的数据类型:long | int | String , 不可以传入复杂类型,如:User 、Admin 等等
    StpUtil.login(Object id);	 
    

    只此一句代码,便可以使会话登录成功,实际上,Sa-Token 在背后做了大量的工作,包括但不限于:

    1. 检查此账号是否之前已有登录
    2. 为账号生成 Token 凭证与 Session 会话
    3. 通知全局侦听器,xx 账号登录成功
    4. Token 注入到请求上下文
    5. 等等其它工作……

    你暂时不需要完整的了解整个登录过程,你只需要记住关键一点:Sa-Token 为这个账号创建了一个 Token 凭证,且通过 Cookie 上下文返回给了前端

    所以一般情况下,我们的登录接口代码,会大致类似如下:

    // 会话登录接口 
    @RequestMapping("doLogin")
    public SaResult doLogin(String name, String pwd) {
    	// 第一步:比对前端提交的账号名称、密码
    	if("zhang".equals(name) && "123456".equals(pwd)) {
    		// 第二步:根据账号 id ,进行登录 
    		StpUtil.login(10001);
    		return SaResult.ok("登录成功");
    	}
    	return SaResult.error("登录失败");
    }
    

    如果你对以上代码阅读没有压力,你可能会注意到略显奇怪的一点:此处仅仅做了会话登录,但并没有主动向前端返回 Token 信息。 是因为不需要吗?严格来讲是需要的,只不过 StpUtil.login(id) 方法利用了 Cookie 自动注入的特性,省略了你手写返回 Token 的代码。

    如果你对 Cookie 功能还不太了解,也不用担心,我们会在之后的 [ 前后端分离 ] 章节中详细的阐述 Cookie 功能,现在你只需要了解最基本的两点:

    • Cookie 可以从后端控制往浏览器中写入 Token 值。
    • Cookie 会在前端每次发起请求时自动提交 Token 值。

    因此,在 Cookie 功能的加持下,我们可以仅靠 StpUtil.login(id) 一句代码就完成登录认证。

    除了登录方法,我们还需要:

    // 当前会话注销登录
    StpUtil.logout();
    
    // 获取当前会话是否已经登录,返回 true=已登录,false=未登录
    StpUtil.isLogin();
    
    // 检验当前会话是否已经登录, 如果未登录,则抛出异常:`NotLoginException`
    StpUtil.checkLogin();
    

    异常 NotLoginException 代表当前会话暂未登录,可能的原因有很多: 前端没有提交 Token 、前端提交的 Token 是无效的、前端提交的 Token 已经过期 …… 等等。

    Sa-Token 未登录场景值参照表:

    场景值 对应常量 含义说明
    -1 NotLoginException.NOT_TOKEN 未能从请求中读取到 Token
    -2 NotLoginException.INVALID_TOKEN 已读取到 Token ,但是 Token 无效
    -3 NotLoginException.TOKEN_TIMEOUT 已读取到 Token ,但是 Token 已经过期
    -4 NotLoginException.BE_REPLACED 已读取到 Token ,但是 Token 已被顶下线
    -5 NotLoginException.KICK_OUT 已读取到 Token ,但是 Token 已被踢下线

    那么,如何获取场景值呢?废话少说直接上代码:

    // 全局异常拦截(拦截项目中的 NotLoginException 异常)
    @ExceptionHandler(NotLoginException.class)
    public SaResult handlerNotLoginException(NotLoginException nle)
    		throws Exception {
    
    	// 打印堆栈,以供调试
    	nle.printStackTrace(); 
    	
    	// 判断场景值,定制化异常信息 
    	String message = "";
    	if(nle.getType().equals(NotLoginException.NOT_TOKEN)) {
    		message = "未提供 token";
    	}
    	else if(nle.getType().equals(NotLoginException.INVALID_TOKEN)) {
    		message = "token 无效";
    	}
    	else if(nle.getType().equals(NotLoginException.TOKEN_TIMEOUT)) {
    		message = "token 已过期";
    	}
    	else if(nle.getType().equals(NotLoginException.BE_REPLACED)) {
    		message = "token 已被顶下线";
    	}
    	else if(nle.getType().equals(NotLoginException.KICK_OUT)) {
    		message = "token 已被踢下线";
    	}
    	else {
    		message = "当前会话未登录";
    	}
    	
    	// 返回给前端
    	return SaResult.error(message);
    }
    

    注意:以上代码并非处理逻辑的最佳方式,只为以最简单的代码演示出场景值的获取与应用,大家可以根据自己的项目需求来定制化处理

    三、会话查询

    // 获取当前会话账号 id, 如果未登录,则抛出异常:`NotLoginException`
    StpUtil.getLoginId();
    
    // 类似查询 API 还有:
    StpUtil.getLoginIdAsString();    // 获取当前会话账号 id, 并转化为`String`类型
    StpUtil.getLoginIdAsInt();       // 获取当前会话账号 id, 并转化为`int`类型
    StpUtil.getLoginIdAsLong();      // 获取当前会话账号 id, 并转化为`long`类型
    
    // ---------- 指定未登录情形下返回的默认值 ----------
    
    // 获取当前会话账号 id, 如果未登录,则返回 null 
    StpUtil.getLoginIdDefaultNull();
    
    // 获取当前会话账号 id, 如果未登录,则返回默认值 (`defaultValue`可以为任意类型)
    StpUtil.getLoginId(T defaultValue);
    

    四、Token 查询

    // 获取当前会话的 token 值
    StpUtil.getTokenValue();
    
    // 获取当前`StpLogic`的 token 名称
    StpUtil.getTokenName();
    
    // 获取指定 token 对应的账号 id ,如果未登录,则返回 null
    StpUtil.getLoginIdByToken(String tokenValue);
    
    // 获取当前会话剩余有效期(单位:s ,返回-1 代表永久有效)
    StpUtil.getTokenTimeout();
    
    // 获取当前会话的 token 信息参数
    StpUtil.getTokenInfo();
    

    TokenInfo 是 Token 信息 Model ,用来描述一个 Token 的常用参数:

    {
    	"tokenName": "satoken",           // token 名称
    	"tokenValue": "e67b99f1-3d7a-4a8d-bb2f-e888a0805633",      // token 值
    	"isLogin": true,                  // 此 token 是否已经登录
    	"loginId": "10001",               // 此 token 对应的 LoginId ,未登录时为 null
    	"loginType": "login",              // 账号类型标识
    	"tokenTimeout": 2591977,          // token 剩余有效期 (单位: 秒)
    	"sessionTimeout": 2591977,        // User-Session 剩余有效时间 (单位: 秒)
    	"tokenSessionTimeout": -2,        // Token-Session 剩余有效时间 (单位: 秒) (-2 表示系统中不存在这个缓存)
    	"tokenActivityTimeout": -1,       // token 剩余无操作有效时间 (单位: 秒)
    	"loginDevice": "default-device"   // 登录设备类型 
    }
    

    五、来个小测试,加深一下理解

    新建 LoginAuthController,复制以下代码

    package com.pj.cases.use;
    
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import cn.dev33.satoken.stp.SaTokenInfo;
    import cn.dev33.satoken.stp.StpUtil;
    import cn.dev33.satoken.util.SaResult;
    
    /**
     * Sa-Token 登录认证示例 
     * 
     * @author kong
     * @since 2022-10-13
     */
    @RestController
    @RequestMapping("/acc/")
    public class LoginAuthController {
    
    	// 会话登录接口  ---- http://localhost:8081/acc/doLogin?name=zhang&pwd=123456
    	@RequestMapping("doLogin")
    	public SaResult doLogin(String name, String pwd) {
    		
    		// 第一步:比对前端提交的 账号名称 & 密码 是否正确,比对成功后开始登录 
    		// 		此处仅作模拟示例,真实项目需要从数据库中查询数据进行比对 
    		if("zhang".equals(name) && "123456".equals(pwd)) {
    			
    			// 第二步:根据账号 id ,进行登录 
    			// 		此处填入的参数应该保持用户表唯一,比如用户 id ,不可以直接填入整个 User 对象 
    			StpUtil.login(10001);
    			
    			// SaResult 是 Sa-Token 中对返回结果的简单封装,下面的示例将不再赘述 
    			return SaResult.ok("登录成功");
    		}
    		
    		return SaResult.error("登录失败");
    	}
    
    	// 查询当前登录状态  ---- http://localhost:8081/acc/isLogin
    	@RequestMapping("isLogin")
    	public SaResult isLogin() {
    		// StpUtil.isLogin() 查询当前客户端是否登录,返回 true 或 false 
    		boolean isLogin = StpUtil.isLogin();
    		return SaResult.ok("当前客户端是否登录:" + isLogin);
    	}
    
    	// 校验当前登录状态  ---- http://localhost:8081/acc/checkLogin
    	@RequestMapping("checkLogin")
    	public SaResult checkLogin() {
    		// 检验当前会话是否已经登录, 如果未登录,则抛出异常:`NotLoginException`
    		StpUtil.checkLogin();
    
    		// 抛出异常后,代码将走入全局异常处理( GlobalException.java ),如果没有抛出异常,则代表通过了登录校验,返回下面信息 
    		return SaResult.ok("校验登录成功,这行字符串是只有登录后才会返回的信息");
    	}
    
    	// 获取当前登录的账号是谁  ---- http://localhost:8081/acc/getLoginId
    	@RequestMapping("getLoginId")
    	public SaResult getLoginId() {
    		// 需要注意的是,StpUtil.getLoginId() 自带登录校验效果
    		// 也就是说如果在未登录的情况下调用这句代码,框架就会抛出 `NotLoginException` 异常,效果和 StpUtil.checkLogin() 是一样的 
    		Object userId = StpUtil.getLoginId();
    		System.out.println("当前登录的账号 id 是:" + userId);
    		
    		// 如果不希望 StpUtil.getLoginId() 触发登录校验效果,可以填入一个默认值
    		// 如果会话未登录,则返回这个默认值,如果会话已登录,将正常返回登录的账号 id 
    		Object userId2 = StpUtil.getLoginId(0);
    		System.out.println("当前登录的账号 id 是:" + userId2);
    		
    		// 或者使其在未登录的时候返回 null 
    		Object userId3 = StpUtil.getLoginIdDefaultNull();
    		System.out.println("当前登录的账号 id 是:" + userId3);
    		
    		// 类型转换:
    		// StpUtil.getLoginId() 返回的是 Object 类型,你可以使用以下方法指定其返回的类型 
    		int userId4 = StpUtil.getLoginIdAsInt();  // 将返回值转换为 int 类型 
    		long userId5 = StpUtil.getLoginIdAsLong();  // 将返回值转换为 long 类型 
    		String userId6 = StpUtil.getLoginIdAsString();  // 将返回值转换为 String 类型 
    		
    		// 疑问:数据基本类型不是有八个吗,为什么只封装以上三种类型的转换?
    		// 因为大多数项目都是拿 int 、long 或 String 声明 UserId 的类型的,实在没见过哪个项目用 double 、float 、boolean 之类来声明 UserId 
    		System.out.println("当前登录的账号 id 是:" + userId4 + " --- " + userId5 + " --- " + userId6);
    		
    		// 返回给前端 
    		return SaResult.ok("当前客户端登录的账号 id 是:" + userId);
    	}
    
    	// 查询 Token 信息  ---- http://localhost:8081/acc/tokenInfo
    	@RequestMapping("tokenInfo")
    	public SaResult tokenInfo() {
    		// TokenName 是 Token 名称的意思,此值也决定了前端提交 Token 时应该使用的参数名称 
    		String tokenName = StpUtil.getTokenName();
    		System.out.println("前端提交 Token 时应该使用的参数名称:" + tokenName);
    		
    		// 使用 StpUtil.getTokenValue() 获取前端提交的 Token 值 
    		// 框架默认前端可以从以下三个途径中提交 Token:
    		// 		Cookie 		(浏览器自动提交)
    		// 		Header 头	(代码手动提交)
    		// 		Query 参数	(代码手动提交) 例如: /user/getInfo?satoken=xxxx-xxxx-xxxx-xxxx 
    		// 读取顺序为:Query 参数 --> Header 头 -- > Cookie 
    		// 以上三个地方都读取不到 Token 信息的话,则视为前端没有提交 Token 
    		String tokenValue = StpUtil.getTokenValue();
    		System.out.println("前端提交的 Token 值为:" + tokenValue);
    		
    		// TokenInfo 包含了此 Token 的大多数信息 
    		SaTokenInfo info = StpUtil.getTokenInfo();
    		System.out.println("Token 名称:" + info.getTokenName());
    		System.out.println("Token 值:" + info.getTokenValue());
    		System.out.println("当前是否登录:" + info.getIsLogin());
    		System.out.println("当前登录的账号 id:" + info.getLoginId());
    		System.out.println("当前登录账号的类型:" + info.getLoginType());
    		System.out.println("当前登录客户端的设备类型:" + info.getLoginDevice());
    		System.out.println("当前 Token 的剩余有效期:" + info.getTokenTimeout()); // 单位:秒,-1 代表永久有效,-2 代表值不存在
    		System.out.println("当前 Token 的剩余临时有效期:" + info.getTokenActivityTimeout()); // 单位:秒,-1 代表永久有效,-2 代表值不存在
    		System.out.println("当前 User-Session 的剩余有效期" + info.getSessionTimeout()); // 单位:秒,-1 代表永久有效,-2 代表值不存在
    		System.out.println("当前 Token-Session 的剩余有效期" + info.getTokenSessionTimeout()); // 单位:秒,-1 代表永久有效,-2 代表值不存在
    		
    		// 返回给前端 
    		return SaResult.data(StpUtil.getTokenInfo());
    	}
    	
    	// 会话注销  ---- http://localhost:8081/acc/logout
    	@RequestMapping("logout")
    	public SaResult logout() {
    		// 退出登录会清除三个地方的数据:
    		// 		1 、Redis 中保存的 Token 信息
    		// 		2 、当前请求上下文中保存的 Token 信息 
    		// 		3 、Cookie 中保存的 Token 信息(如果未使用 Cookie 模式则不会清除)
    		StpUtil.logout();
    		
    		// StpUtil.logout() 在未登录时也是可以调用成功的,
    		// 也就是说,无论客户端有没有登录,执行完 StpUtil.logout() 后,都会处于未登录状态 
    		System.out.println("当前是否处于登录状态:" + StpUtil.isLogin());
    		
    		// 返回给前端 
    		return SaResult.ok("退出登录成功");
    	}
    	
    }
    

    代码注释已针对每一步操作做出详细解释,大家可根据可参照注释中的访问链接进行逐步测试。

    本示例代码已上传至 Gitee ,可参考: Sa-Token 登录认证示例


    参考资料

    6 条回复    2023-05-11 15:12:48 +08:00
    needpp
        1
    needpp  
       2023-05-09 12:35:00 +08:00
    非常好用的框架,设计的很漂亮; 对于跨域建议把 option 拦截去掉
    StevenQAQ
        2
    StevenQAQ  
       2023-05-09 14:11:55 +08:00
    看起来很清爽,配置类很少
    subframe75361
        3
    subframe75361  
       2023-05-09 21:34:57 +08:00
    毕设用到了,被 spring security 繁琐的认证恶心到之后找到的,很简洁!
    teem
        4
    teem  
       2023-05-10 21:10:00 +08:00
    动态图用啥工具画的?
    konglizhi3362
        5
    konglizhi3362  
    OP
       2023-05-11 05:42:07 +08:00
    @teem ppt 动画导出为 gif 图片
    conglovely
        6
    conglovely  
       2023-05-11 15:12:48 +08:00
    哇哦,非常好使 轻便~
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   3152 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 25ms · UTC 14:00 · PVG 22:00 · LAX 06:00 · JFK 09:00
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.