前言
上节我们完成了基础的注册功能及全局异常处理,这节我们开始登录业务的开发,关于登录其实没多少内容,重点还是在于如何保持用户登录的状态。
由于项目时前后端分离,这里就采用头部token的方式确保接口的访问及用户状态管理,这里就不用jwt等工具自己做状态管理,直接使用国产的一款优秀的框架sa-token,集成了 token、权限管理等,感兴趣的同学自己了解学习一下。前端我们采用pinia来进行用户状态管理,个人认为pinia比vuex好用的多,自从使用了vue3后就没有再用过vuex来进行用户状态管理了。
1 后端开发
1.1 引入sa-token依赖
<!-- Sa-Token 权限认证,在线文档:https://sa-token.cc -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>1.37.0</version>
</dependency>
1.2 sa-token配置
创建com.ruoxi.config.SaTokenConfig类
package com.ruoxi.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
/**
* sa-token 参数配置
*/
@Configuration
public class SaTokenConfig {
/**
* sa-token 参数设置
*
* @return
*/
@Bean
@Primary
public cn.dev33.satoken.config.SaTokenConfig getSaTokenConfigPrimary() {
cn.dev33.satoken.config.SaTokenConfig config = new cn.dev33.satoken.config.SaTokenConfig();
// token 名称(同时也是 cookie 名称)
config.setTokenName("Authorization");
// #token前缀,例如填写 Bearer 实际传参 satoken: Bearer xxxx-xxxx-xxxx-xxxx 参考:自定义Token前缀
config.setTokenPrefix("Bearer");
// token 有效期(单位:秒),默认30天,-1代表永不过期
config.setTimeout(-1);
// token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
config.setActiveTimeout(1800);
// 是否允许同一账号多地同时登录(为 true 时允许一起登录,为 false 时新登录挤掉旧登录)
config.setIsConcurrent(true);//建议开启,多云桌面环境下有利于用户体验
// 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token,为 false 时每次登录新建一个 token)
config.setIsShare(true);//建议开启,这样可减少session的创建,降低内存资源消耗。
// token 风格
config.setTokenStyle("uuid");
// 是否尝试从 cookie 里读取 Token,此值为 false 后,StpUtil.login(id) 登录时也不会再往前端注入Cookie
config.setIsReadCookie(false);
// 是否尝试从 body 里读取 Token
config.setIsReadBody(false);
// 是否尝试从 header 里读取 Token
config.setIsReadHeader(true);
// 是否输出操作日志
config.setIsLog(false);
// 定时清理过期数据周期(单位:秒);默认30秒,0代表不开启定时任务
config.setDataRefreshPeriod(60);
return config;
}
}
1.3 会话工具类SessionUtil
sa-token的会话类为SaSession,在未对接其他缓存插件时,sa-token的会话内容都会保存在内存中由SaSession存储,开发过程中我们可以直接通过StpUtil.getSession()获取。但是如果后面sa-token要收费了或者老板不让用sa-token了,我们一个个找SaSession并替换其他工具,这是一件非常糟糕的事。这里我们就自己定义一个SessionUtil工具类,来实现一定程度上的解耦。创建com.ruoxi.util.SessionUtil类
package com.ruoxi.util;
import cn.dev33.satoken.stp.StpUtil;
import com.ruoxi.model.entity.SysUser;
/**
* 会话工具类
*/
public class SessionUtil {
public static final String USER_KEY = "USER";
public static Object get(String key){
return StpUtil.getSession().get(key);
}
public static <T> T get(String key, Class<T> cs){
return StpUtil.getSession().getModel(key,cs);
}
public static void set(String key,Object value){
StpUtil.getSession().set(key,value);
}
public static void clear(){
StpUtil.getSession().clear();
}
/**
* 获取当前用户信息
* @return
*/
public static SysUser getUser(){
return StpUtil.getSession().getModel(USER_KEY, SysUser.class);
}
/**
* 设置当前用户信息
* @param user
*/
public static void setUser(SysUser user){
StpUtil.getSession().set(USER_KEY, user);
}
}
1.4 基础登录服务
这里我们不仅要满足账号密码登录的方式,还要考虑到第三方平台的登录方式,所以我们需要创建一个通用的登录方法,该方法只关注“登录”这件事,而不需要去关注登录方式、用户信息来源等。创建com.ruoxi.service.LoginService类。
package com.ruoxi.service;
import cn.dev33.satoken.stp.StpUtil;
import com.ruoxi.exception.BusinessException;
import com.ruoxi.model.entity.SysUser;
import com.ruoxi.util.Assert;
import com.ruoxi.util.SessionUtil;
import org.springframework.stereotype.Service;
/**
* 登录服务
*/
@Service
public class LoginService {
/**
* 登录
* @param user
* @return
*/
public boolean login(SysUser user){
//校验用户信息
if(user==null){
throw new BusinessException("用户信息缺失",new NullPointerException());
}
//校验用户状态
Assert.isTrue(user.getDelFlag().equals("1"),"用户状态异常");
//校验用户登录权限
Assert.isTrue(false,"权限不足");
//登录
StpUtil.login(user.getUserId());
//在会话中保存用户信息
SessionUtil.setUser(user);
return true;
}
}
这里我们还没有进行权限设计,在校验登录权限处我们默认放行即可。
利用satoken的登录方法StpUtil.login()创建一个id为userId的会话,实现管理用户状态。
这里我们需要注意下属性SysUser,这个类里面存在password字段,如果直接返回则会暴露用户密码。还有在其它使用的地方可能都有泄露的风险,因此需要对SysUser类进行小小的改动,在password属性上添加注释@TableField(select = false),添加该注解的好处就是使用Mybatis-Plus查询用户信息时会自动忽略查询该字段。
package com.ruoxi.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.ruoxi.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@TableName("sys_user")
public class SysUser extends BaseEntity {
/**
* 用户ID
*/
@TableId(type=IdType.ASSIGN_ID)
private String userId;
/**
* 用户账号
*/
private String userAccount;
/**
* 密码
* @TableField(select = false) 不查询
*/
@TableField(select = false)
private String password;
/**
* 昵称
*/
private String nickName;
/**
* 电话
*/
private String phone;
/**
* 邮箱
*/
private String email;
/**
* 性别
*/
private String sex;
/**
* 部门ID
*/
private String deptId;
/**
* 部门名称
*/
private String deptName;
/**
* 状态
*/
private String status;
}
1.5 账号密码登录逻辑
基于上面的登录方法,使用账号密码的登录方式,我们仅需进行账号密码的验证即可,之后的事情交给loginService.login即可。在SysUserService中添加loginByAccount方法:
package com.ruoxi.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoxi.mapper.SysUserMapper;
import com.ruoxi.model.entity.SysUser;
import com.ruoxi.util.Assert;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@AllArgsConstructor
public class SysUserService extends ServiceImpl<SysUserMapper, SysUser> {
private SysUserMapper userMapper;
private LoginService loginService;
/**
* 通过账户密码的方式注册用户
* @param user 用户信息
* @return true:注册成功 false:注册失败
*/
public boolean registerByAccount(SysUser user) {
//校验用户账号唯一
Assert.isTrue(!checkUserAccountUnique(user),"账号已被注册");
//保存账号信息
return userMapper.insert(user) > 0;
}
/**
* 通过账户密码的方式登录
* @param user
* @return
*/
public boolean loginByAccount(SysUser user) {
//校验用户账号密码
user= userMapper.selectOne(new QueryWrapper<SysUser>().eq("user_account",user.getUserAccount())
.eq("password",user.getPassword()));
Assert.isNull(user,"账号或密码错误");
//登录
return loginService.login(user);
}
//----------------校验方法----------------
/**
* 校验用户账号唯一
* @param sysUser 用户信息
* @return true:唯一 false:重复
*/
public boolean checkUserAccountUnique(SysUser sysUser) {
return !userMapper.exists(new QueryWrapper<SysUser>().eq("user_account",sysUser.getUserAccount()));
}
}
1.6 创建登录服务接口
创建com.ruoxi.LoginController
package com.ruoxi.controller;
import cn.dev33.satoken.stp.StpUtil;
import com.ruoxi.model.dto.HttpResp;
import com.ruoxi.model.entity.SysUser;
import com.ruoxi.service.SysUserService;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/login")
@AllArgsConstructor
public class LoginController {
private SysUserService userService;
/**
* 登录
* @param user 用户账号、密码
* @return
*/
@PostMapping("/byAccount")
public HttpResp loginByAccount(@RequestBody @NotNull(message = "用户账、密码不能为空") SysUser user){
//登录
userService.loginByAccount(user);
return HttpResp.success().data("user", user).data("tokenInfo", StpUtil.getTokenInfo());
}
}
接口的参数SysUser主要用来接收用户账号、密码参数,其前面的@NotNull就是参数验证工具validate中的的注解,作用是当SysUser为空即没有任何参数传进来时抛出校验异常,异常消息就是message的内容。
2 前端开发
2.1 登录页面
登录页面我们在上节就已经写好了,这里我们只需要写好接口调用函数即可,创建src/api/Login.js
import http from "@/plugins/axios";
//账号密码登录
export const loginByAccount = ({ userAccount, password }) => {
return http({
url: "/login/byAccount",
method: "post",
data: {
userAccount,
password
}
});
};
修改登录表单的登录方法
import { loginByAccount } from "@/api/Login";
//...
//登录按钮操作
const handleLogin = () => {
formRef.value.validate((valid) => {
if (valid) {
loginByAccount(formData.value)
.then(() => {
ElMessage.success("登录成功");
loading.value = false;
})
.catch((message) => {
ElMessage.error(message);
});
}
});
};
改好后,启动前、后端程序,浏览器访问登录页(http://localhost:5173/#/login),输入账号密码 后提交。
2.2 用户状态
上面我们已经完成了基本的登录交互,登录之后各个页面、功能按钮都需要根据用户权限信息进行判断是否展示等,当用户登录过期时还要跳转到登录页重新登录等等,这些都依赖用户于的状态。为了减轻后端服务的压力,这里可以再登录后将这些频繁使用的用户信息及状态存储在前端进行通义管理和使用,只有在一些必要的节点才从后端获取数据。
2.2.1 pinia安装
在创建vue项目时如果选择了安装pinia,这一步就可以跳过了。控制台输入npm install pinia,等待安装完成即可。
安装完成后,我们还需安装一个pinia持久化插件pinia-plugin-persist。使用过pinia或者vuex的同学应该知道,当我们在这些插件的store中保存了数据后,如果用户刷新了页面,那么store就重置了,之前保存的数据就丢失了,为了解决该问题,我们的做法就是在store发生变化时同步备份到浏览器的localStorage或sessionStorage中,当界面刷新时通过这些storage进行填充恢复。pinia-plugin-persist的工作原理也是如此,好处就在于我们不需要在手动去写触发、备份、恢复等相关方法,直接在pinia中配置该插件即可实现。控制台输入npm install pinia-plugin-persist,等待安装完成。
安装成功后,引入插件,创建src/stores/index.js文件:
import { createPinia } from "pinia";
const pinia = createPinia();
//持久化插件
import piniaPersist from "pinia-plugin-persist";
pinia.use(piniaPersist);
export default pinia;
这里我们引入persist插件后,需要修改一下main.js中pinia的导入路径,由之前的直接引入组件修改该为引入src/stores/index.js:
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
const app = createApp(App);
//pinia
import pinia from "./stores/index";
app.use(pinia);
//element plus message css
import "element-plus/es/components/message/style/css";
import "element-plus/es/components/message-box/style/css";
//element plus icon
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
//全局css,一定要放到所有组件后面,否则无法覆盖组件样式
import "./assets/css/main.css";
app.use(router);
app.mount("#app");
2.2.2 登录相关store
这里涉及到两个store,一是用户状态管理类userStore:主要保存用户信息包括基础信息、角色、权限等,用于后面对前端展示内容进行控制,由于这里我们还没有写权限相关内容,只罗列用户信息相关变量和方法。二是token状态管理类tokenStore:主要保存token相关信息,如传给后端的token参数名tokenName,token的值tokenValue及颁发时间、计算到期时间等等,用于检查用户登录状态,提供更友好的交互。
为了防止页面刷新导致丢失store内容,这里我们使用了上述的persist插件,具体用法见defineStore的第三个参数,其它的一些高级用法可自行参考其官网。
创建src/stores/modules/userStore.js:
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { useTokenStore } from "./tokenStore";
import http from "@/plugins/axios/index";
/**
* 用户状态管理
*/
export const useUserStore = defineStore(
"userStore",
() => {
//用户信息
const userInfo = ref({
/**
* userId
* userAccount
* userName
* ....
*/
});
//账号密码登录
const loginByAccount = ({ userAccount, password }) => {
return new Promise((resolve, reject) => {
http({
url: "/login/byAccount",
method: "post",
data: { userAccount, password }
})
.then(({ user, tokenInfo }) => {
//登录成功
//设置用户信息
userInfo.value = user;
//设置token信息
const tokenStore = useTokenStore();
tokenStore.init(tokenInfo);
//返回成功消息
resolve("登录成功");
})
.catch((message) => {
//登录失败
//返回失败消息
reject(message);
});
});
};
//退出登陆
const logout = () => {
//清空用户信息
userInfo.value = {};
//清空token信息
const tokenStore = useTokenStore();
tokenStore.clear();
http({
url: "/login/logout",
method: "post"
});
};
return {
userInfo: computed(() => userInfo.value),
loginByAccount,
logout
};
},
{
persist: {
enabled: true, //开启持久化插件
strategies: [{ storage: localStorage }] //数据同步至localStorage
}
}
);
创建src/stores/modules/tokenStore.js:
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import http from "@/plugins/axios/index";
/**
* token状态管理
*/
export const useTokenStore = defineStore(
"tokenStore",
() => {
//token 名称
const tokenName = ref();
//token 值
const tokenValue = ref();
// 过期时长
const expiresIn = ref(0);
// 过期时间
const expiredTime = ref(0);
//初始化
const init = ({ tokenName: name, tokenValue: value, tokenActiveTime }) => {
tokenName.value = name;
tokenValue.value = value;
expiresIn.value = tokenActiveTime * 1000;
expiredTime.value = new Date().getTime() + tokenActiveTime * 1000;
};
//清空
const clear = () => {
tokenName.value = null;
tokenValue.value = null;
expiresIn.value = null;
expiredTime.value = null;
};
//刷新
const refresh = async () => {
let success = false;
await http({
url: "/login/refreshToken",
method: "post"
})
.then(({ tokenInfo }) => {
init(tokenInfo);
success = true;
})
.catch(() => {
success = false;
});
return success;
};
//是否有效
const isValidate = async () => {
//没有token
if (!tokenValue.value) {
return false;
}
//过期
if (expiredTime.value <= new Date().getTime()) {
return await refresh();
}
return true;
};
return {
tokenName: computed(() => tokenName.value),
tokenValue: computed(() => tokenValue.value),
expiresIn: computed(() => expiresIn.value),
expiredTime: computed(() => expiredTime.value),
init,
clear,
refresh,
isValidate
};
},
{
persist: {
enabled: true, //开启持久化插件
strategies: [{ storage: localStorage }] //数据同步至localStorage
}
}
);
2.2.3 用户登录状态由userStore管理
创建userStore的目的就是为了管理用户状态的,所以这里我们需要把登录表单的登录逻辑交给userStore处理,试想一下如果一个系统存在多个入口页面,如一些博客网站、素材网站,往往是允许用户再未登录的状态下进行预览,但是再发表评论、下载文件等操作时需要进行登录,此时一个网站就有多个可以登录的入口,开发者需要再每个地方都需要调用登录接口,然后将用户信息、token信息等等保存在store中,这样代码不仅重复、繁杂,当登录业务需求发生变化时,还要一个一个去找去改,非常麻烦。如果交给store来管理,开发时就不需要操心那么多,直接调用store中的登录方法就可以了。
修改LoginForm.vue文件:
<script setup>
import { ref } from "vue";
import { ElMessage } from "element-plus";
import { useUserStore } from "@/stores/modules/userStore";
const userStore = useUserStore();
//表单引用对象
const formRef = ref();
//表单数据
const formData = ref({
userAccount: "",
password: ""
});
//表单校验规则
const formRule = ref({
userAccount: [
{ required: true, message: "请输入用户名", trigger: "blur" },
{ type: "string", min: 6, max: 20, message: "用户名长度在6-20位之间", trigger: "blur" }
],
password: [
{ required: true, message: "请输入密码", trigger: "blur" },
{ type: "string", min: 8, max: 32, message: "用户名长度在8-32位之间", trigger: "blur" }
]
});
//登录加载状态
const loading = ref(false);
//登录按钮操作
const handleLogin = () => {
formRef.value.validate((valid) => {
if (valid) {
userStore
.loginByAccount(formData.value)
.then(() => {
ElMessage.success("登录成功");
loading.value = false;
console.log();
})
.catch((message) => {
console.log(message);
ElMessage.error(message);
});
}
});
};
</script>
<template>
<el-form class="register-form" ref="formRef" :model="formData" :rules="formRule" size="large">
<el-form-item prop="userAccount">
<el-input v-model="formData.userAccount" placeholder="请输入用户名" clearable autocomplete="off"></el-input>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="formData.password" placeholder="请输入密码" autocomplete="off" type="password" :showPassword="true"> </el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleLogin" :loading="loading"> 登录 </el-button>
</el-form-item>
</el-form>
</template>
<style scoped>
.el-button {
width: 100%;
}
</style>
以上就是本节内容啦,如有问题欢迎随时私信,文章发布的可能会慢些,私信会及时回复哒。
项目gitee地址:https://gitee.com/git1375661475/ruoxi.git