1、ruoyi-ui项目中login.vue的< el-input >标签讲解(前端)
<el-input> 标签内属性介绍:
<el-input
v-model="listQuery.orderId" 数据绑定
placeholder="orderId" 当输入框内容为空时的占位符
style="width: 200px;" 输入框宽度
class="filter-item" class名称
@keyup.enter.native="handleFilter" 当按下回车时触发事件调用方法
@keyup.native="handleFilter" 当按钮回弹时触发的方法
>
</el-input>
<!-- @click与@keyup的区别 ※※※
1、其中@click用于绑定监听功能:
<button @click="test1">test1</button>
<button @click="test2('abc')">test2</button>
<button @click="test3('abcd', $event)">test3</button>
methods: {
test1(eve) {//test1函数没有参数,默认传递 $event
alert(eve.target.innerHTML) //test1
},
test2 (msg) { //test1函数有参数,传递该参数
alert(msg) // abc
},
test3 (msg, event) { //有参数,如果想获取到enevt,则函数中需要写 $event
alert(msg+'---'+event.target.textContent) // abcd---test3
}
}
2、而@keyup用于按键修饰符:
//按下enter时,执行方法test7
<input type="text" @keyup.enter="test7">
methods: {
test7 (event) {
console.log(event.keyCode)
alert(event.target.value)
}
}
-->
<!--
这里关于<el-input></el-input>的不理解,是因为还未学习ElementUI,关于该框架的学习见后文
-->
2、login.vue中在< el-input >标签内出现的< svg-icon >标签讲解(前端)
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
type="text"
auto-complete="off"
placeholder="账号"
>
<svg-icon slot="prefix" icon-class="user" class="el-input__icon input-icon" />
</el-input>
</el-form-item>
-
首先要明白< svg-icon >标签是一个全局组件,要在对应的icons/index.js中定义并注册成立全局组件;需要svg-sprite-loader的配合,并在config.js中填写相关配置;其图片文件都放在icons/svg文件夹下面。
-
如何使用< svg-icon >组件:
2.1. 安装svg-sprite-loader依赖,而这个在若依的准备工作,运行npm install时就给安装好了。
2.2. 配置vue.config.js:
//开头
const path = require('path')
function resolve(dir) {
return path.join(__dirname, dir)
}
//这里只列一部分,具体配置参考文档
module.exports = {
chainWebpack(config) {
config.plugins.delete('preload') // TODO: need test
config.plugins.delete('prefetch') // TODO: need test
// set svg-sprite-loader
config.module
.rule('svg')
.exclude.add(resolve('src/assets/icons'))
.end()
config.module
.rule('icons')
.test(/\.svg$/)
.include.add(resolve('src/assets/icons'))
.end()
.use('svg-sprite-loader')
.loader('svg-sprite-loader')
.options({
symbolId: 'icon-[name]'
})
.end()
//这里后面仍可以按需添加config.module操作
}
}
2.3. 创建相关文件及文件夹(以若依框架中为例):
2.4.在main/js中引入icons:
2.5.使用< svg-icon >组件(以若依框架中为例):
这里< svg-icon >用在< el-input >< /el-input >内部,其中slot表示所引用图片显示的位置:prefix为input框之前,suffix为框尾部;icon-class表示它引用的是src/assets/icons/svg下的哪一张图片;class为对应css中所定义的样式(同时注意base、head、html、meta、param、script、title这几个不支持使用class)。
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
type="text"
auto-complete="off"
placeholder="账号"
>
<svg-icon slot="prefix" icon-class="user" class="el-input__icon input-icon" /> //suffix表示尾部
</el-input>
</el-form-item>
框前:
class所用的input-icon样式:
3、登录按钮对应代码(前端 --> 后端 --> 前端)
//登录按钮对应的keyup回车响应事件
handleLogin() {
this.$refs.loginForm.validate(valid => {
if (valid) {
this.loading = true;
//如果勾选了“记住密码”选项,就把相关信息存到Cookie中
if (this.loginForm.rememberMe) {
Cookies.set("username", this.loginForm.username, { expires: 30 });
Cookies.set("password", encrypt(this.loginForm.password), { expires: 30 });
Cookies.set('rememberMe', this.loginForm.rememberMe, { expires: 30 });
} else {
//如果没有勾选“记住密码”选项,就把相关信息在Cookie中移除
Cookies.remove("username");
Cookies.remove("password");
Cookies.remove('rememberMe');
}
this.$store.dispatch("Login", this.loginForm).then(() => {
this.$router.push({ path: this.redirect || "/" }).catch(()=>{});
}).catch(() => {
this.loading = false;
if (this.captchaEnabled) {
this.getCode();
}
});
}
});
}
关于$store.dispatch方法介绍如下:点我,其中“Login”追溯过去,可以发现其位置与内容如下图所示:
其功能大致为获取表单信息,然后又封装了一个对象Promise,用于实现异步处理,因为Promise中又调用了另一个js中的Login函数,如下图所示:
上图将传过来的数据封装到data中,然后return表示的是带着data数据对url进行post请求,如下图所示,便是在前端抓包抓到的login.js文件中data数据内容:
下图为return requset内容:
且通过上图可以看出,这里又使用了反向代理操作,映射到8080(一看到return request操作,就要想到(一)中讲解的理论请求后端,结果请求前端的反向代理操作)。并且可以在后端找到login注解所在函数位置。
此时若在AjaxResult ajax = AjaxResult.success();
处打一断点,你会发现此时login函数所传入的 LoginBody 变量的内容,正是前端所填写与生成的内容:
(其中AjaxResult ajax = AjaxResult.success();
为SpringBoot [后端]的通用返回类,以此来返回状态码信息)
3.1、在后端的login函数中接收到前端传过来的数据后,后端要做哪些事清呢?大体上可总结为三件事:
- 验证校验码:
在longin函数中调用了一个封装函数loginService.login()
来进行验证操作码方式
点进去后可发现其函数代码如下问所示:
public void validateCaptcha(String username, String code, String uuid)
{
//用于获取验证码开关
boolean captchaEnabled = configService.selectCaptchaEnabled();//用于获取验证码开关
if (captchaEnabled)
{
//这一步是通过使用固定前缀与uuid的组合,使其能够在Redis中找到对应的缓存键值
String verifyKey = CacheConstants.CAPTCHA_CODE_KEY + StringUtils.nvl(uuid, "");
//获取缓存键值对应的对象
String captcha = redisCache.getCacheObject(verifyKey);
//删除Redis缓存中对应缓存键值的对象
redisCache.deleteObject(verifyKey);
//当Rdeis中对应缓存对象为空时(即存入Redis后超过2分钟)
if (captcha == null)
{
//execute是与线程相关,这句用于异步记录日志,使用工厂方法,可实现解耦和,此句与下面的抛出过期异常可同时执行
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")));
//抛出过期异常
throw new CaptchaExpireException();
}
//当code值与captcha值不相等时
if (!code.equalsIgnoreCase(captcha))
{
//同上面所讲(业务层面需要)
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")));
throw new CaptchaException();
}
}
}
这里之所以没有对正常情况进行处理,是因为若正常他就会直接往下面走,执行下一个验证用户名和密码操作;而不是被抛出的异常而打断该方法进程
- 验证用户名和密码:
2.1:进行完验证码的校验操作后,
进行登录的前置校验,目的是将用户名与密码中那些不合乎规定的直接排除在外,代码如下所示:
/**
* 登录前置校验
* @param username 用户名
* @param password 用户密码
*/
public void loginPreCheck(String username, String password)
{
// 用户名或密码为空 错误
if (StringUtils.isEmpty(username) || StringUtils.isEmpty(password))
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("not.null")));
throw new UserNotExistsException();
}
// 密码如果不在指定范围内 错误
if (password.length() < UserConstants.PASSWORD_MIN_LENGTH
|| password.length() > UserConstants.PASSWORD_MAX_LENGTH)
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
throw new UserPasswordNotMatchException();
}
// 用户名不在指定范围内 错误
if (username.length() < UserConstants.USERNAME_MIN_LENGTH
|| username.length() > UserConstants.USERNAME_MAX_LENGTH)
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
throw new UserPasswordNotMatchException();
}
// IP黑名单校验
String blackStr = configService.selectConfigByKey("sys.login.blackIPList");
if (IpUtils.isMatchedIp(blackStr, IpUtils.getIpAddr()))
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("login.blocked")));
throw new BlackListException();
}
}
2.2:而后使用Spring Security进行用户验证(本质就是设置Filter过滤器),代码如下所示:
// 用户验证
Authentication authentication = null;
try
{
//这里是创建一个UsernamePasswordAuthenticationToken对象,并将username与password进行赋值操作,就是进行一个包装操作
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
//
AuthenticationContextHolder.setContext(authenticationToken);
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager.authenticate(authenticationToken);
}
catch (Exception e)
{
if (e instanceof BadCredentialsException)
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
throw new UserPasswordNotMatchException();
}
else
{
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
throw new ServiceException(e.getMessage());
}
}
finally
{
AuthenticationContextHolder.clearContext();
}
//如果成功,异步写操作日志,是写道sys_logininfo这张表中
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
//拿到登录用户
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
//记录账户信息,写到日志中;这一步修改的是数据库中sys_user表中的login_date这一项
//且这里你对recordLoginUnfo一步步追踪,可发现他获取到了userid、ip以及登陆时间;
recordLoginInfo(loginUser.getUserId());
2.3:校验用户名和密码中部分知识点讲解:
2.3.1:在recordLoginInfo(loginUser.getUserId());
如何获取的Ip?(Spring Boot项目在不同文件夹下,两种调用java类的方式)
在SysLoginService.java文件中可遭到recordLoginInfo的代码,如下图所示:
再追溯过去,可发现他是对原方法进行了一层封装,在IpUtils.getIpAddr()中通过ServletUtils工具类中的getRequest()方法获取到了IP。
但是!!你会发现IpUtils.java文件是在ruoyi-common文件夹中的,那么在ruoyi-admin中的SysLoginController.java是如何调用到IpUtils.Java这个工具类的呢?
答案在ruoyi-admin中的pom文件中,已经提前对ruoyi-framework、ruoyi-quartz以及ruoyi-generator另外三个包配置了依赖,因而在SysLoginController.java中可以调用ruoyi-framwork包中的SysLoginService.java,又因为在SysLoginService.java有对ruoyi-common包的导入,因而可以调用IpUtils.Java工具类。(Spring Boot项目在不同文件夹下,两种调用java类的方式)
这里ruoyi-admin引入了上文提到的三个包,因而在Controller类使用时就要引入其他未添加依赖且需要的包:
这里ruoyi-framework仅引入了ruoyi-system包,因而在Service类使用时就要引入其他未添加依赖且需要的包:
2.3.2:更新用户信息
跟进去,代码如下所示:
这里是调用Mapper,当这个出现的时候,证明它开始与mybatis一起发力了,再跟进去会看到mybatis的相关代码(需在idea下载MyBatisX插件),如下图所示:
- 生成Token
3.1.在SysLoginService.java中return tokenService.createToken(loginUser);
用于生成token
3.2.跟进去可以发现下列代码:
/**
* 创建令牌
*
* @param loginUser 用户信息
* @return 令牌
*/
public String createToken(LoginUser loginUser)
{
//获取uuid
String token = IdUtils.fastUUID();
//把获取到的uuid存到用户里面
loginUser.setToken(token);
//用于获取ip、浏览器和os等,并存到用户里面(跟进去发现的)
setUserAgent(loginUser);
//刷新token令牌有效期,并将用户登陆信息loginUser存到Redis中
refreshToken(loginUser);
//这里的claims是jwt中加载所用到的用户信息
Map<String, Object> claims = new HashMap<>();
claims.put(Constants.LOGIN_USER_KEY, token);
//用于从数据声明生成令牌,生成点进createToken中会发现调用的是jwt,如下面代码所示
return createToken(claims);
}
/**
* 从数据声明生成令牌
*
* @param claims 数据声明
* @return 令牌
*/
private String createToken(Map<String, Object> claims)
{
String token = Jwts.builder()
.setClaims(claims)
//用这个算法将信息生成字符串(加密操作)
.signWith(SignatureAlgorithm.HS512, secret).compact();
return token;
}
最终生成的token通过ajax返回前端。
3.2、handLogin()方法流程小总结
首先看到前端对应返回的代码处,跟着Login这个action点进去
会有下图所示代码:
其中的login()方法你依次追进,会发现如下代码:
对应着后台调用的方法,因而其方法内的 setToken(res.token)
(经点进去可发现)是用于将token存到Cookies中(因为将token令牌存至了Cookies中,因而此用户在30min内再次登录【不点击退出的那种登录】,浏览器会自动登录此用户,无需输入密码验证码)。
3.3、那么回到前端后,前端再如何运行呢?
红框1中所标内容便是带着前台数据到后台进行操作,而红框2中便是后端在对表单信息内容全部检验合格后进行的操作(原因:从前面操作可看出若是验证码校验和用户校验不合格时,会抛出异常,因而也就走不到这里),即跳转到下一个页面,流程如下图所示:
3.4、由上述过程中进而衍生出了一个问题,watch()监听方法中route的值是如何得到的?
watch()监听代码:
watch: {
$route: {
handler: function(route) {
console.log("route的值: " + route);
console.log("route.query 和 route.query.redirect 的值: " + route.query +" " + route.query.redirect);
this.redirect = route.query && route.query.redirect;
},
immediate: true
}
},
首先,我们知道$route
是获取当前路由信息对象,包含路由的状态信息、URL解析得到的信息,还有URL匹配到的路由信息。其次route.query表示URL查询参数(若没有查询参数,为空),如下图所示,可看到这里的route.query的值为\index
。
(在查看URLEncode编码之后的数据可以知晓 ‘ %2F ’ 就表示 ’ / ')
3.5、那么URL路由中%2Findex的值是从哪里开始定义的呢?(全局路由守卫和判定)
(若依框架的前端路由控制的核心是在src/permission.js中)
首先找到了ruoyi-ui/src/permission.js中,增加这么一句:
会发现这里的to.fulPath便是URL中/login?redirect=%2Findex
permission代码及解读注释如下:
(注意:当未登陆过,首次打开login.vue页面时,是执行if ( getToken() ) { } else{ }
中else内的内容,因为token令牌是在登录按钮点击后才生成的 )
import router from './router'
import store from './store'
import { Message } from 'element-ui'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import { getToken } from '@/utils/auth'
import { isRelogin } from '@/utils/request'
//这玩意是进度条
NProgress.configure({ showSpinner: false })
const whiteList = ['/login', '/register']
//挂载路由导航守卫:这里是全局前置路由守卫————初始化及每次路由切换之前调用
router.beforeEach((to, from, next) => {
//to 将要访问的路径
//from 从哪个路径跳转而来
//next是一个函数,表示放行;next() 放行,next('/login') 强制跳转
console.log(" default test: " + to.fullPath);
console.log(" to.path : " + to.path);
//请求路由进度条开始
NProgress.start()
//如果有token令牌(即登陆成功)
if (getToken()) {
//设置页面标题
to.meta.title && store.dispatch('settings/setTitle', to.meta.title)
/* has token*/
//如果要访问登录页面,直接放行
if (to.path === '/login') {
//放行至首页,这是取决于你的路由重定向路径
next({ path: '/' })
//请求路由进度条结束
NProgress.done()
}else {//如果要访问的不是登录页面
//判断是否已经获取用户信息
if (store.getters.roles.length === 0) { //如果为0,表示未获取用户信息,需要重新获取
//重新登陆弹窗
isRelogin.show = true
// 判断当前用户是否已拉取完user_info信息
store.dispatch('GetInfo').then(() => {
isRelogin.show = false
//从vue中触发GenerateRoutes方法,获取后台返回的路由信息(也就是在这里获取到了路由的对应字符串)
store.dispatch('GenerateRoutes').then(accessRoutes => {
// 根据roles权限生成可访问的路由表(即上面两个方法完成后,动态添加到路由中,并渲染到页面上)
router.addRoutes(accessRoutes) // 动态添加可访问路由表
next({ ...to, replace: true }) // hack方法 确保addRoutes已完成
})
}).catch(err => {
//获取用户信息失败,清除token,跳转到登录页面进行重新登陆操作
store.dispatch('LogOut').then(() => {
Message.error(err)
next({ path: '/' })
})
})
} else {
next()
}
}
} else { // 如果没有token令牌
if (whiteList.indexOf(to.path) !== -1) {
// 在免登录白名单,直接放行
next()
} else {
next(`/login?redirect=${to.fullPath}`) // 否则全部重定向到登录页
//console.log(" default test: " + to.fullPath);
NProgress.done()
}
}
})
router.afterEach(() => {
//每次请求结束后,进度条结束
NProgress.done()
})
这里经在控制台输出语句检测到,首次进入此页面时,是进入到下图所示中放行的:
流程图所示:
4、GenerateRouters生成路由时,字符串如何通过过滤器转换为组件对象
以GenerateRouters方法中的const sidebarRoutes = filterAsyncRouter(sdata)
为例,其filterAsyncRouter
方法代码如下所示:
// 遍历后台传来的路由字符串,转换为组件对象
function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false) {
return asyncRouterMap.filter(route => {
if (type && route.children) {
route.children = filterChildren(route.children)
}
// 字符串变组件操作
if (route.component) {
// Layout ParentView 组件特殊处理
if (route.component === 'Layout') {
route.component = Layout
} else if (route.component === 'ParentView') {
route.component = ParentView
} else if (route.component === 'InnerLink') {
route.component = InnerLink
} else { //若route.component中的字符串与上面三个不匹配则进行下面的操作
route.component = loadView(route.component)
}
}
if (route.children != null && route.children && route.children.length) {
route.children = filterAsyncRouter(route.children, route, type)
} else {
delete route['children']
delete route['redirect']
}
return true
})
}
上面代码中提到的loadView
方法如下面所示:
此函数可根据传过来的view内容,利用require对${view}进行替换后,形成一个组件
// 箭头函数的使用
export const loadView = (view) => {
if (process.env.NODE_ENV === 'development') {
// 其中require是用于找组件的
return (resolve) => require([`@/views/${view}`], resolve)
} else {
// 使用 import 实现生产环境的路由懒加载
return () => import(`@/views/${view}`)
}
}
// 将箭头函数转化为function函数
function loadView(view){
return function(resolve) {require(['@/views/${view}'],resolve)}
}
5、关于登录流程中token的校验(利用过滤器)
流程如下(借用波波的图):
使用的技术栈
已知后台给前台返回数据时,使用的是Jwt给前台生成一个token值。
其中SysLoginService.java中使用的return tokenService.createToken(loginUser);
,调用的下列函数方法:
/**
* 创建令牌
*
* @param loginUser 用户信息
* @return 令牌
*/
public String createToken(LoginUser loginUser)
{
String token = IdUtils.fastUUID();
loginUser.setToken(token);
setUserAgent(loginUser);
refreshToken(loginUser);
Map<String, Object> claims = new HashMap<>();
claims.put(Constants.LOGIN_USER_KEY, token);
return createToken(claims);
}
而此处createToken的return中调用的createToken方法如下:
/**
* 从数据声明生成令牌
*
* @param claims 数据声明
* @return 令牌
*/
private String createToken(Map<String, Object> claims)
{
String token = Jwts.builder()
.setClaims(claims)
.signWith(SignatureAlgorithm.HS512, secret).compact();
return token;
}
因此可知最后使用Jwt来生成token。
token的校验
在登陆成功后,每一个请求发送到后台,都需要对token进行权限校验,若依框架对于token的校验是用过滤器实现的。(过滤器位于:ruoyi-framework/com/ruoyi/framework/security/filter包中的JwtAuthenticationTokenFilter,代码展示如下)
/**
* token过滤器 验证token有效性
*
* @author ruoyi
*/
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
{
@Autowired
private TokenService tokenService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException
{
LoginUser loginUser = tokenService.getLoginUser(request);
if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication()))
{
tokenService.verifyToken(loginUser);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
chain.doFilter(request, response);
}
}
{
从getLoginUser中追踪可发现使用了getToken方法,且对header使用了getHeader方法,(header使在TokenService.java中自定义的令牌标识),也就是对从前端传过来的request的头部信息中取到token值(这个值就是前置拦截器放入的)
然后将token返回getLoginUser方法。
}
{
随后在此方法中进行对token的解析操作
这里获取的uuid正是前面的createToken方法使用String token = IdUtils.fastUUID();
生成的uuid,并将uuid放到Redis中。
然后 String userKey = getTokenKey(uuid);
这里的getTokenKey是把uuid作为参数,拼接成想要的数据模式,然后使用userkey去Redis中拿值及用户的详细信息userLoginUser user = redisCache.getCacheObject(userKey);
。(user值展示:)
}
通过上述操作,会得到LoginUser loginUser对象,随后便是对loginUser用户信息进行其他判断:
首先利用tokenService.verifyToken(loginUser);
来验证token的有效期,然后 UsernamePasswordAuthenticationToken authenticationToken
是对loginUser的token令牌时间进行一个刷新,最后执行chain.doFilter(request, response);
,过滤器放行。