01
前言
有这样一个业务场景,比如有一个后台管理系统,需要实现登录认证,如果比较简单的认证方式就是传递用户名和密码后端进行查询数据库检验是否存在,这样虽然方便,但是存在很多潜在的风险,比如SQL注入,暴力破解等非法请求。目前主流的安全框架有Spring Security(代码超级繁琐复杂,用过都知道,太重)、Shiro(虽然相比Spring Security灵活,但是需要各种配置,麻烦)等。那么今天推荐一款GitHub热榜的安全框架Sa-Token,目前已有16.9k的Star,相当火爆。而且这个项目的宗旨就是用极少的代码就可以实现安全的认证、授权等功能。
目前该项目社区活跃度超级高,作者为了帮助使用者,还搭建了一个Sa-Token的在线文档网站,甚至不懂的问题还可以联系客服进行解答。很少见过如此把开发者当上帝的开源项目了。
该开源项目的GitHub地址:https://github.com/dromara/sa-token
官方网站:https://sa-token.cc/
02
如何接入
那么下面带大家使用SpringBoot3+Vue3+Sa-Token框架来实现登录认证。这里说明一点,因为我使用的是前后端分离,所以我将采用jwt的形式生成token,而且使用Redis进行存储token,并且设置过期时间。如果您是采用单体项目,推荐使用Cookie形式进行传递token,官网文档有详细的步骤代码。
首先在pom文件中先导入一下Sa-Token相关的依赖
<!-- Sa-Token 权限认证 springboot3.x使用该依赖 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>1.39.0</version>
</dependency>
<!-- 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>
<!--BCrypt密码加密-->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<!-- sa-token集成jwt-->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-jwt</artifactId>
<version>1.39.0</version>
</dependency>
sa-token:
# token 名称(同时也是 cookie 名称)
token-name: satoken
# token 有效期(单位:秒) 默认30天,-1 代表永久有效
timeout: 2592000
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
active-timeout: 1800
# 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
is-concurrent: false
# 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)
is-share: false
# token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik)
token-style: random-128
# 配置jwt密钥 这里是我随机输入的密钥
jwt-secret-key: dksDid23wHfAwDawdW2hsgbDduvGwu2e1
# token前缀
token-prefix: Bearer
# 是否输出操作日志
is-log: true
#是否尝试从 cookie 里读取 Token,此值为 false 后,StpUtil.login(id) 登录时也不会再往前端注入Cookie
#注意:当使用 redis 作为缓存数据时要将此关闭,否则不会将redis作为缓存,而是继续使用cookie
is-read-cookie: false
创建一个拦截器,拦截除登录、注册以外的所有请求,进行身份验证。
@Configuration
public class MyWebMvcConfig implements WebMvcConfigurer {
private static final String ORIGINS[] = new String[]{"GET", "POST", "PUT", "DELETE", "OPTIONS"};
@Override
public void addInterceptors(InterceptorRegistry registry) {
//登录拦截器
registry.addInterceptor(new SaInterceptor(new SaParamFunction<Object>() {
// 是否登录
@Override
public void run(Object o) {
// 调用sa-token提供的方法,校验用户身份登录
StpUtil.checkLogin();
}
}))
//将所有路径都拦截
.addPathPatterns("/**")
//排除不拦截的路径
.excludePathPatterns("/user/login","/user/logout","/user/register")
.order(1);
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") // 所有的当前站点的请求地址,都支持跨域访问。
.allowedOriginPatterns("*") // 所有的外部域都可跨域访问。 如果是localhost则很难配置,因为在跨域请求的时候,外部域的解析可能是localhost、127.0.0.1、主机名
.allowCredentials(true) // 是否支持跨域用户凭证
.allowedMethods(ORIGINS) // 当前站点支持的跨域请求类型是什么
.maxAge(3600); // 超时时长设置为1小时。 时间单位是秒。
}
}
在Controller控制层编写登录和退出
@RestController
@RequestMapping("/user")
@CrossOrigin
public class UserController {
@Autowired
private UserService userService;
@RequestMapping("/login")
public Result<LoginVo> login(@RequestBody LoginDto loginDto) {
if (!StringUtils.hasLength(loginDto.getUsername()) || !StringUtils.hasLength(loginDto.getPassword())) {
return Result.fail(401, "用户名或密码为空!");
}
return userService.login(loginDto);
}
// 退出登录接口
@PostMapping("/logout")
public Result<String> logout() {
// 调用sa-token的退出登录方法,只需一行代码搞定
StpUtil.logout();
return Result.success("退出成功");
}
}
service层代码编写
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private TokenUtil tokenUtil;
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;
@Override
public Result<LoginVo> login(LoginDto loginDto) {
User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getUsername, loginDto.getUsername()));
if (user == null) {
return Result.fail(401, "用户不存在!");
}
//获取数据库加密的password
if (loginDto.getUsername().equals(user.getUsername()) && bCryptPasswordEncoder.matches(loginDto.getPassword(), user.getPassword())) {
//sa-token进行登录
StpUtil.login(user.getId());
//设置返回实体
LoginVo loginVo = new LoginVo();
loginVo.setUserId(user.getId());
loginVo.setPicture(user.getPicture());
loginVo.setNickname(user.getNickname());
loginVo.setToken(StpUtil.getTokenValue());
return Result.success(loginVo);
}
return Result.fail(401, "用户名或密码错误!");
}
}
创建全局异常拦截器,拦截Sa-Token抛出的错误NotLoginException,根据自己的返回message需要进行修改。
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 登录异常拦截器(拦截项目中的NotLoginException异常)
* @return {@link Result}
*/
@ResponseBody
@ExceptionHandler(value = NotLoginException.class)
public Result<String> exceptionHandler(NotLoginException nle) {
log.info("发送{}异常", nle.getLocalizedMessage());
// 判断场景值,定制化异常信息
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)) {
//token过期
message = "登录已过期,请重新登录";
}
else if(nle.getType().equals(NotLoginException.BE_REPLACED)) {
message = "您已被顶下线";
}
else if(nle.getType().equals(NotLoginException.KICK_OUT)) {
message = "您已被踢下线";
}
else if(nle.getType().equals(NotLoginException.TOKEN_FREEZE)) {
//token冻结
message = "登录已过期,请重新登录";
}
else if(nle.getType().equals(NotLoginException.NO_PREFIX)) {
message = "未按照指定前缀提交token";
}
else {
message = "当前会话未登录";
}
return Result.fail(401, message);
}
}
设置前端的请求和响应拦截器,请求拦截器需要每次请求携带token,而headers中的key一定要和后端yml配置中的token-name保持一致。
const BASE_URL = process.env.NODE_ENV === 'production' ? 'ecm-api' : 'api'
const httpInstance = axios.create({
baseURL: BASE_URL, // 基地址
timeout: 5000 // 超时器
})
//拦截器
httpInstance.interceptors.request.use(config => {
const auth = useAuthStore()
// 2. 按照后端的要求拼接token数据
const token = auth.token;
if (token) {
config.headers.satoken = `Bearer ${token}`;
}
return config
}, e => Promise.reject(e))
//响应器
httpInstance.interceptors.response.use(
(response) => {
const data = response.data;
const code = data?.code;
const message = data?.message || '请求失败';
if (code === 200) {
return data.data;
} else if (code === 401) {
ElMessage.error(message);
//跳转至登录页重新登录
return Promise.reject(router.replace('/login'));
} else {
ElMessage.error(message);
return Promise.reject(new Error(message || '系统出现未知错误'));
}
},
(error) => {
ElMessage.error('网络错误,请稍后再试');
return Promise.reject(error);
})
export default httpInstance
前端Login.vue页面,只提供逻辑代码,样式代码可以根据自己业务进行调整。
<template>
<el-form
ref="loginForm"
:model="loginData"
label-width="auto"
class="login-form"
:rules="rules"
>
<el-form-item class="form-item" prop="username">
<el-input
v-model="loginData.username"
placeholder="请输入用户名"
prefix-icon="User"
size="large"
></el-input>
</el-form-item>
<el-form-item class="form-item" prop="password">
<el-input
v-model="loginData.password"
placeholder="请输入密码"
type="password"
show-password
prefix-icon="Lock"
size="large"
></el-input>
</el-form-item>
</el-form>
<div class="btn">
<el-button
type="primary"
class="login-btn"
@click="submitForm(loginForm)"
:loading="isBtnLoading"
>登录</el-button
>
</div>
</template>
<script setup lang='ts' name="Login">
import { useAuthStore } from "@/store/auth";
import { reactive, ref} from "vue";
import { useRouter } from "vue-router";
import { ElMessage, type FormInstance } from "element-plus";
const auth = useAuthStore();
const router = useRouter();
const isBtnLoading = ref(false);
let loginData = ref({ username: "", password: "" });
const loginForm = ref<FormInstance>();
const rules = reactive({
username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
password: [{ required: true, message: "请输入密码", trigger: "blur" }]
});
const submitForm = (formEl: FormInstance | undefined) => {
if (!formEl) return;
formEl.validate((valid) => {
if (!valid) {
return;
}
isBtnLoading.value = true;
api.post("/user/login", loginData.value).then((data:any) => {
const token = data.token;
const userInfo = {
userId: data.userId,
nickname: data.nickname,
picture: data.picture,
};
//将pinia登录状态改为登录 使用了pinia-plugin-persistedstate插件持久化
auth.login(token, userInfo);
ElMessage.success("登录成功");
//跳转至管理台
router.push("/home");
isBtnLoading.value = false;
}).catch((error) => {
isBtnLoading.value = false;
});
});
};
</script>
030202020202
END
Sa-Token的功能还远不止这些,还有单点登录、Oauth2.0、权限认证、微服务等,如果小伙伴们感兴趣可以到官网进行进一步的探究。感谢您观看阿龙的文章,记得关注哦~02如何接入