前后端分离使用Sa-Token(超越官方文档)
讲解了前后端分离框架下使用sa-token技术完成登录校验,权限校验。
前后端分离使用Sa-Token(超越官方文档)
前言
Sa-Token的官方文档链接🔗https://sa-token.cc/doc.html在此。
事先声明,起一个这样的标题并不是我狂妄自大,而且Sa-Token的官方文档是我见过的少有的写的很好的官方文档(很多知名项目的官方文档可以说一言难尽)。但是,关于前后端分离的这部分,我感到Sa-Token官方文档实在有些过于简略。而我在学习Sa-Token的时候也遇到一个困境,就是关于前后端分离的文章在百度谷歌上基本找不来,大部分所谓原创都是对官方文档的复制粘贴,照搬。因此,在我掌握前后端分离对Sa-Token的使用后,我决定写这样一篇文章,方便后来者对Sa-Token的前后端分离的使用有一个参考。
在前后端分离的权限验证,登录校验这条路上。我自学习编程以来,先后经历过传统token,jwt,单点登录,shiro,Spring Security等。这些技术,有的需要你书写大篇幅的拦截器,过滤器,资源类控制逻辑。可谓苦不堪言。而且官方文档一塌糊涂,使用步骤极度繁琐。最终,我找到了一个目前我认为最完美的技术:Sa-Token。当今国内企业,大部分项目都是前后端分离的,Sa-Token美中不足的地方在于,它的官方文档对前后端不分离的部分讲述的很清楚,对我们真正要经常用到前后端分离的使用讲述的却过于简略。这篇文档,我将从头到尾讲解如何使用Sa-Token做前后端分离的Spring Boot项目。
请注意,大部分代码需要你结合实际,甚至需要你稍加改动才能用。我会默认你已经掌握了jwt,cookie,session等知识。我将在每一段代码内写上注释,而且讲解我的写作逻辑,但这不意味着你可以不思考就能掌握这项技术。我已经将我的代码开源放在github,你也可以拉取GitHub的代码在本地运行,更方便你理解这项技术。GitHub的链接🔗https://github.com/PerCheung/learnsatoken在此。
步骤
1.创建一个spring boot项目,引入如下两个依赖。
<!-- Sa-Token 权限认证,在线文档:https://sa-token.cc -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>1.35.0.RC</version>
</dependency>
<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
<version>1.35.0.RC</version>
</dependency>
讲解:第一个是sa-token的自动装配,无需多言。第二个,则会将我们的token自动管理在redis中,而不是在cookie或者session,这样就达到了最完美的前后端分离状态,无论你是重启前端还是后端,销毁cookie还是session,都不影响项目的token。如果你对单点登录,分布式登录有了解,你就会深深明白这么做的好处。在跨域的单点登录里,cookie是失效的,前后端分离,分布式的项目里,一个请求根本不知道到来的请求在跟哪个session打交道。我的代码只求最彻底,最激进,最完善的前后端分离。
2.配置你的application.yml。
这里需要注意,在spring boot data redis的2版本以后,已经弃用 jedis 改用 lettuce,而官网仍在配置jedis连接池,这里要用lettuce连接池。
############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
sa-token:
# token 名称(同时也是 cookie 名称)
token-name: token
# token 有效期(单位:秒) 默认30天,-1 代表永久有效
timeout: -1
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
active-timeout: -1
# 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
is-concurrent: false
# token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik)
token-style: simple-uuid
# 是否输出操作日志
is-log: true
# 是否尝试从 cookie 里读取 Token,此值为 false 后,StpUtil.login(id) 登录时也不会再往前端注入Cookie
isReadCookie: false
spring:
# Redis配置
redis:
host: localhost
port: 6379
# 根据自己设置的密码决定
password: 你的redis密码
# 操作0号数据库,默认有16个数据库
database: 0
lettuce:
pool:
# 最大连接数
max-active: 500
# 连接池最大阻塞等待时间
max-wait: 1000ms
# 连接池中的最大空闲连接
max-idle: 100
# 连接池中的最小空闲连接
min-idle: 0
讲解:如果你用过jwt,那么token-name: token就可以达到类似于jwt的效果,在你的header加上token:token值,就可以达到登录的校验的效果,像jwt一样,完全抛弃了cookie和session,而jwt过于冗余,token-style: simple-uuid将保证token的精简,结合token的唯一性配合redis的使用,jwt携带信息的优点也将不复存在,因为redis可以存储一切。而redis配置你不必担心它影响spring boot自带的redis自动装配依赖,结合实际你会发现sa-token就是封装的redis自动装配依赖。当你创造一个token,他也会自动写入redis,不需要你多写任何代码。所有的token,都将会自动管理。
3.注解鉴权
加sa-token拦截器。
import cn.dev33.satoken.interceptor.SaInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* @author Peter Cheung
* 2023/7/19 13:52
*/
@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {
// 注册 Sa-Token 拦截器,打开注解式鉴权功能
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册 Sa-Token 拦截器,打开注解式鉴权功能
registry.addInterceptor(new SaInterceptor()).addPathPatterns("/api/**");
}
}
讲解:/api最好写上,然后把你的接口都带上/api,因为/**什么都扫描,效率低下。如果你只做登录校验,这么点代码就已经足够。
如果你还想做权限,角色的校验,加上下面的代码。
import cn.dev33.satoken.stp.StpInterface;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
/**
* @author Peter Cheung
* 2023/7/19 16:55
*/
@Service
public class StpInterfaceImpl implements StpInterface {
/**
* 返回一个账号所拥有的权限码集合
*/
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
// 本 list 仅做模拟,实际项目中要根据具体业务逻辑来查询权限
List<String> list = new ArrayList<String>();
list.add("101");
list.add("user.add");
list.add("user.update");
list.add("user.get");
// list.add("user.delete");
list.add("art.*");
return list;
}
/**
* 返回一个账号所拥有的角色标识集合 (权限与角色可分开校验)
*/
@Override
public List<String> getRoleList(Object loginId, String loginType) {
// 本 list 仅做模拟,实际项目中要根据具体业务逻辑来查询角色
List<String> list = new ArrayList<String>();
if ("root".equals(loginId)) {
list.add("admin");
list.add("super-admin");
}
return list;
}
}
讲解:具体用法我不再赘述,如果你学过shiro,那你理解这段代码轻而易举,无非是把一个角色数组或者权限数据,跟账号做了一个绑定。如果你真的想理解掌握,然后超越,去改造这段代码,需要你不得不去学习一下shiro的五表思想去分配角色和权限资源。才能让你真正在实际工作里拿捏对权限角色的分配管理。
4.加入RedisConfig。
这和sa-token无关,只是为了让你的redis能支持中文存储。
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis配置类,更换默认序列化器
*
* @author Peter Cheung
* @since 2023-07-28 13:51:55
*/
@Configuration
public class RedisConfig extends CachingConfigurerSupport {
/**
* 创建 RedisTemplate Bean,用于操作 Redis 数据库。
*
* @param connectionFactory Redis 连接工厂
* @return RedisTemplate 实例
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// 设置 RedisTemplate 的连接工厂
redisTemplate.setConnectionFactory(connectionFactory);
// 创建 StringRedisSerializer 实例,用于序列化和反序列化 Redis 的键和哈希键
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
// 创建 GenericJackson2JsonRedisSerializer 实例,用于序列化和反序列化 Redis 的值和哈希值
GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
// 设置默认序列化器为 StringRedisSerializer
redisTemplate.setDefaultSerializer(stringRedisSerializer);
// 设置 RedisTemplate 的键序列化器为 StringRedisSerializer
redisTemplate.setKeySerializer(stringRedisSerializer);
// 设置 RedisTemplate 的哈希键序列化器为 StringRedisSerializer
redisTemplate.setHashKeySerializer(stringRedisSerializer);
// 设置 RedisTemplate 的值序列化器为 GenericJackson2JsonRedisSerializer
redisTemplate.setValueSerializer(genericJackson2JsonRedisSerializer);
// 设置 RedisTemplate 的哈希值序列化器为 GenericJackson2JsonRedisSerializer
redisTemplate.setHashValueSerializer(genericJackson2JsonRedisSerializer);
return redisTemplate;
}
}
5.异常统一处理
如果你的Java知识不足以理解这些代码,这些也可以不要,下面的代码只是为了让你错误处理更加优雅。
import javax.validation.ValidationException;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import static com.learn.learnsatoken.config.constant.Constant.PACKAGE_NAME;
/**
* 全局异常统一处理
*
* @author Peter Cheung
* @since 2023-07-19 11:37:24
*/
@Slf4j
@ControllerAdvice
@ResponseBody
public class AllExceptionHandle {
/**
* 登录权限校验
*/
@ExceptionHandler({NotLoginException.class, NotRoleException.class})
public ResponseEntity<R> unauthorized(Exception e) {
return R.deal(R.unauthorized().data(e(e)));
}
/**
* 校验传参
*/
@ExceptionHandler(ValidationException.class)
public ResponseEntity<R> handleBadRequest(Exception e) {
return R.deal(R.badRequest().data(e(e)));
}
/**
* 全局异常处理
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<R> exception(Exception e) {
return R.deal(R.exp().data(e(e)));
}
/**
* 异常信息处理主体方法
*
* @param e 异常对象
* @return 异常解析信息
*/
private String e(Exception e) {
ByteArrayOutputStream printStackTrace = new ByteArrayOutputStream();
e.printStackTrace(new PrintStream(printStackTrace));
log.error(String.valueOf(printStackTrace));
//错误信息
StringBuilder errorMessage = new StringBuilder();
errorMessage.append(e);
if (StringUtils.isBlank(e.getMessage())) {
//处理
log.error(String.valueOf(errorMessage));
return String.valueOf(errorMessage);
}
StackTraceElement[] stackTrace = e.getStackTrace();
for (StackTraceElement stackTraceElement : stackTrace) {
String className = stackTraceElement.getClassName();
if (className.startsWith(PACKAGE_NAME)) {
String errorName = ";" + stackTraceElement.getClassName();
errorMessage.append(errorName);
String errorLineNumber = ":" + stackTraceElement.getLineNumber();
errorMessage.append(errorLineNumber);
//处理
log.error(String.valueOf(errorMessage));
return String.valueOf(errorMessage);
}
}
//处理
log.error(String.valueOf(errorMessage));
return String.valueOf(errorMessage);
}
}
你只需关注的代码是这一段。
/**
* 登录权限校验
*/
@ExceptionHandler({NotLoginException.class, NotRoleException.class})
public ResponseEntity<R> unauthorized(Exception e) {
return R.deal(R.unauthorized().data(e(e)));
}
讲解:登录,权限的不足,都可以理解成401错误,这是http的知识,这段代码将会把sa-token的报错都以401错误返回给前端,让前端一看便知:哦,原来是用户的问题,不是后端错了。
6.业务代码controller层
import cn.dev33.satoken.annotation.SaCheckBasic;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckRole;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import com.learn.learnsatoken.mvc.domain.User;
import com.learn.learnsatoken.mvc.service.UserService;
import com.learn.learnsatoken.util.MD5Util;
import com.learn.learnsatoken.util.R;
import com.learn.learnsatoken.util.UUIDUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
/**
* 用户表(User)表控制层
*
* @author Peter Cheung
* @since 2023-07-19 11:37:23
*/
@CrossOrigin(origins = "*", methods = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.HEAD})
@Slf4j
@RestController
@RequestMapping("api/user")
@Api(tags = "用户表(User)表控制层")
public class UserController {
/**
* 服务对象
*/
@Resource
private UserService userService;
@Resource
private RedisTemplate<String, String> redisTemplate;
/**
* 注册
*/
@GetMapping("register")
public ResponseEntity<R> register(User user) {
String password = user.getPassword();
String salt = UUIDUtil.toUUID();
user.setSalt(salt);
user.setPassword(MD5Util.toMD5(password, salt));
return R.deal(this.userService.insert(user));
}
@GetMapping("login")
public ResponseEntity<R> doLogin(User user) {
String username = user.getUsername();
User data = (User) userService.queryById(username).getData();
if (data == null) {
return R.deal(R.unauthorized().data("no user"));
}
if (data.getPassword().equals(MD5Util.toMD5(user.getPassword(), data.getSalt()))) {
StpUtil.login(username);
return R.deal(R.ok().data(StpUtil.getTokenInfo().getTokenValue()));
}
return R.deal(R.unauthorized().data("wrong password"));
}
@GetMapping("logout")
public SaResult logout(String name, String token) {
redisTemplate.opsForValue().set("最近五分钟访问的ip", "我是?123456", 5, TimeUnit.MINUTES);
//StpUtil.logout(name);
StpUtil.logoutByTokenValue(token);
return SaResult.ok();
}
/**
* 全查询
*
* @param user 筛选条件
* @return 查询结果
*/
@SaCheckLogin
@ApiOperation("全查询")
@GetMapping
public ResponseEntity<R> queryAll(@ApiParam(value = "user 筛选条件") User user) {
return R.deal(this.userService.queryAll(user));
}
@SaCheckBasic(account = "admin:admin")
@GetMapping("basic")
public ResponseEntity<R> basic(String token) {
Object username = StpUtil.getLoginIdByToken(token);
return R.deal(R.ok().data(username));
}
/**
* 通过主键查询单条数据
*
* @param id 主键
* @return 单条数据
*/
@SaCheckRole("admin")
@ApiOperation("通过主键查询单条数据")
@GetMapping("{id}")
public ResponseEntity<R> queryById(@ApiParam(value = "id 主键") @PathVariable("id") String id) {
return R.deal(this.userService.queryById(id));
}
}
讲解:你要注意到,这里面有几个注解@SaCheckLogin
,@SaCheckRole("admin")
,第一个就是登录校验,第二个就是admin角色校验。你观察我的login部分。StpUtil.login(username);
这句话,就能保证token以username为key,创造token写入redis并且管理起来,完全无需太多代码。而StpUtil.getTokenInfo().getTokenValue()
将把token拿出来返回给前端,前端要记得把token保存起来,最好是放前端的localStorage(后端不懂这是什么,前端很懂)里,只要前端记得后面给带@SaCheckLogin
这样的接口发请求的时候,把token放header里传过来就行。
7.前端代码。
为了方便你的理解和使用,我特意写了一html代码供你测试使用,代码已经写了大篇幅的注释,我就不讲解了。
<!DOCTYPE html>
<html>
<head>
<title>My Page</title>
<meta charset="UTF-8">
<!-- 引用jQuery库 -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script>
$(document).ready(function() {
// 当页面加载完成时执行以下代码
$("#login").click(function() {
// 当“Login”按钮被点击时执行以下代码
var username = $("#username").val(); // 获取输入框中的用户名
var password = $("#password").val(); // 获取输入框中的密码
var url = "http://localhost:8081/api/user/login?username=" + username + "&password=" + password; // 构造用于登录的URL
$.get(url, function(data) { // 发送GET请求
if (data.code == 200) { // 如果返回码为200则表示登录成功
localStorage.setItem("token", data.data); // 将token保存到localStorage中
alert("Login successful!"); // 弹出登录成功提示框
} else {
alert("Login failed: " + data.msg); // 弹出登录失败提示框
}
});
});
$("#get-data").click(function() {
// 当“Get Data”按钮被点击时执行以下代码
var token = localStorage.getItem("token"); // 获取localStorage中保存的token
if (token == null || token == "") {
alert("Please login first!"); // 如果token为空则表示未登录,弹出提示框
return;
}
var url = "http://localhost:8081/api/user"; // 构造请求数据的URL
$.ajax({
url: url,
type: "GET",
headers: {
"token": token // 在请求头中添加token
},
success: function(data) { // 请求成功回调函数
$("#result").val(JSON.stringify(data)); // 将返回的数据展示到文本框中
},
error: function(xhr, status, error) { // 请求失败回调函数
alert("Error: " + error); // 弹出错误提示框
}
});
});
});
</script>
</head>
<body>
<h1>Welcome to My Page</h1>
<form>
<label for="username">Username:</label>
<input type="text" name="username" id="username"><br><br>
<label for="password">Password:</label>
<input type="password" name="password" id="password"><br><br>
<input type="button" value="Login" id="login"> <!-- 点击该按钮发起登录请求 -->
</form>
<br>
<textarea id="result" rows="10" cols="50"></textarea><br>
<input type="button" value="Get Data" id="get-data"> <!-- 点击该按钮发起获取数据请求 -->
</body>
</html>
结语
以上就是精华内容,如果你不太理解,还是建议你,把GitHub上的代码🔗https://github.com/PerCheung/learnsatoken拉下来实操一遍。光看不如上手做一遍。对了,我的user表sql如下。如果你想要了解shiro五表的使用,方便你理解sa-token对权限的管理,我也有shiro的开源项目链接🔗https://github.com/PerCheung/learnshiro。
drop database shiro_learn;
create database shiro_learn;
use shiro_learn;
create table user
(
username varchar(36) primary key comment '用户名',
password varchar(32) not null comment '密码',
salt varchar(36) not null comment '盐',
create_time datetime default now() comment '创建时间',
update_time datetime default now() comment '修改时间',
deleted int default 0 comment '逻辑删除'
) comment '用户表'
engine = innodb
default charset = utf8mb4;
create trigger user_update
before update
on user
for each row set new.update_time = now();
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)