• 若依对SpringSecurity框架的运用



    引言:借助 ruoyi-vue框架学习其对 SpringSecurity框架的运用。若依的前后端分离版本基于 SpringSecurityJWT配合 Redis来做用户状态记录.

    一、SpringSecurity

    1.1 入口

    1. 后台接收登录数据,基于用户名和密码封装一个(UsernamePasswordAuthenticationToken)认证对象
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
    
    • 1
    1. 然后通过SpringScurity的安全管理器调用authenticate()方法,传入刚才创建的认证对象进行认授权认证

      // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
      authentication = authenticationManager.authenticate(authenticationToken);
      
      • 1
      • 2

      若依框架中,这个安全管理器是在配置类中手动注入容器的

      @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
      public class SecurityConfig extends WebSecurityConfigurerAdapter {
      
      	/**
       	* 解决 无法直接注入 AuthenticationManager
       	* echoo mark:手动注入认证管理器
       	*/
      	@Bean
      	@Override
      	public AuthenticationManager authenticationManagerBean() throws Exception {
          	return super.authenticationManagerBean();
      	}
      
      	省略...
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15

      @EnableGlobalMethodSecurity: 开启方法级安全保护,包含了@Configuration

    2. 安全管理器调用authenticate()方法,会进入UserDetailsServiceImpl.loadUserByUsername()方法做登录校验操作,这个UserDetailsServiceImpl若依实现的,loadUserByUsername()方法里就是若依自定义的登录逻辑。这里跳过了一些细节,就是如何保证authenticate()方法用的是若依自定义的登录逻辑?这个是通过重写WebSecurityConfigurerAdapter这个安全适配器里面的configure()方法来指定的。

      首先可以看到配置类是继承了WebSecurityConfigurerAdapter这个父类的,然后通过重写configure(AuthenticationManagerBuilder auth)方法来指定用户详情业务对象userDetailsService,这个userDetailsService就是若依自定义的认证业务对象。

      @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
      public class SecurityConfig extends WebSecurityConfigurerAdapter {
      	/** 自定义用户认证逻辑 */
      	@Autowired
      	private UserDetailsService userDetailsService;
      
      	/** 身份认证接口 */
      	@Override
      	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
          	auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
      	}
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
    3. 再来看这个若依自定义的业务,这个业务实现了springframework.securityUserDetailsService接口,并通过实现它的loadUserByUsername(String username)方法自定义认证逻辑。

      @Service
      public class UserDetailsServiceImpl implements UserDetailsService {
      
      	// echoo mark:登录逻辑
      	@Override
      	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
          	SysUser user = userService.selectUserByUserName(username);
          	if (StringUtils.isNull(user)) {
              	log.info("登录用户:{} 不存在.", username);
              	throw new ServiceException("登录用户:" + username + " 不存在");
          	} else if (UserStatus.DELETED.getCode().equals(user.getDelFlag())) {
              	log.info("登录用户:{} 已被删除.", username);
              	throw new ServiceException("对不起,您的账号:" + username + " 已被删除");
          	} else if (UserStatus.DISABLE.getCode().equals(user.getStatus())) {
              	log.info("登录用户:{} 已被停用.", username);
              	throw new ServiceException("对不起,您的账号:" + username + " 已停用");
         		}
          	passwordService.validate(user); // 校验密码
          	return createLoginUser(user);
      	}
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
    4. 回到入口,也就是authenticate(authenticationToken)方法会去调用UserDetailsService.loadUserByUsername(String username)方法的具体实现UserDetailsServiceImpl.loadUserByUsername(String username)去做登录认证。完美!

      // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
      authentication = authenticationManager.authenticate(authenticationToken);
      
      • 1
      • 2
    5. 最后看一下若依的登录逻辑:根据用户名找到用户→校验密码→创建登录用户数据、填充权限数据并缓存

      // echoo mark:登录逻辑
      @Override
      public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
          SysUser user = userService.selectUserByUserName(username);
          if (StringUtils.isNull(user)) {
              log.info("登录用户:{} 不存在.", username);
              throw new ServiceException("登录用户:" + username + " 不存在");
          } else if (UserStatus.DELETED.getCode().equals(user.getDelFlag())) {
              log.info("登录用户:{} 已被删除.", username);
              throw new ServiceException("对不起,您的账号:" + username + " 已被删除");
          } else if (UserStatus.DISABLE.getCode().equals(user.getStatus())) {
              log.info("登录用户:{} 已被停用.", username);
              throw new ServiceException("对不起,您的账号:" + username + " 已停用");
          }
          passwordService.validate(user); // 校验密码
          return createLoginUser(user);   // 创建登录用户数据
      }
      
      /**
       * 生成缓存用户对象,填充权限数据
       */
      public UserDetails createLoginUser(SysUser user) {
          return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24

    上面是用户认证登录的基本逻辑,后续还有登录状态相关的逻辑。

    二、JWT

    2.1 登录生成token

    登录认证后就是关于登录状态的逻辑了。
    首先是token令牌的创建

        /**
         * 创建令牌
         */
        public String createToken(LoginUser loginUser) {
            String token = IdUtils.fastUUID();
            loginUser.setToken(token); // 在登录对象里保存一份token数据
            setUserAgent(loginUser);   // 设置用户代理信息
            refreshToken(loginUser);
    
            Map<String, Object> claims = new HashMap<>();
            claims.put(Constants.LOGIN_USER_KEY, token);
            return createToken(claims);
        }
        
        /**
         * 设置用户代理信息
         *
         * @param loginUser 登录信息
         */
        public void setUserAgent(LoginUser loginUser) {
            UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));
            String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
            loginUser.setIpaddr(ip);
            loginUser.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
            loginUser.setBrowser(userAgent.getBrowser().getName());
            loginUser.setOs(userAgent.getOperatingSystem().getName());
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    User-Agent是用户代理信息,包含了客户使用的操作系统及版本、CPU 类型、浏览器及版本、浏览器渲染引擎、浏览器语言、浏览器插件等信息
    在这里插入图片描述

    生成jwt令牌:

        /**
         * 从数据声明生成令牌
         * @param claims 数据声明
         * @return 令牌
         */
        private String createToken(Map<String, Object> claims) {
            String token = Jwts.builder()
            		// Map claims = new HashMap<>();
            		// claims.put(Constants.LOGIN_USER_KEY, token);
                    .setClaims(claims) 
                    .signWith(SignatureAlgorithm.HS512, secret).compact();
            return token;
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    其中signWith()方法用来配置jwt生成token时用的算法和密钥,然后调用compact()来打包压缩生成一个jwt专用token

        @Override
        public JwtBuilder signWith(SignatureAlgorithm alg, String base64EncodedSecretKey) {
            Assert.hasText(base64EncodedSecretKey, "base64-encoded secret key cannot be null or empty.");
            Assert.isTrue(alg.isHmac(), "Base64-encoded key bytes may only be specified for HMAC signatures.  If using RSA or Elliptic Curve, use the signWith(SignatureAlgorithm, Key) method instead.");
            byte[] bytes = TextCodec.BASE64.decode(base64EncodedSecretKey); // 把密钥解码成二进制数组
            return signWith(alg, bytes); // 给 JwtBuilder 实例配置算法和密钥
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    经过加密算法加密后的token
    eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6IjdlMzczNDFlLTJlNjQtNDRkZC1hMTU1LTkyMTE0NDQ2NzBjMyJ9.rBblkvEk81768K0tTj0FCaApvqIwFHGKoHxXiZXTJiGcdqhq8gbbzFMwdG-h4FCVFMsTjHSPZe3Dr5at0jqv6g

    这个token包含了登录用户的登录参数,如登录用户唯一的uuid,在后续请求中将会用到。

    2.2 请求解析token

    三、前端 vue

    3.1 验证码图片

    页面元素,验证码以img元素展示。点击触发getCode()方法获取后台验证码。

    <div class="login-code">
    	<img :src="codeUrl" @click="getCode" class="login-code-img"/>
    div>
    
    • 1
    • 2
    • 3

    这里的getCode()方法调用来自login.js文件的getCodeImg()方法

    import {getCodeImg} from "@/api/login";
    
        methods: {
          getCode() { // 获取验证码
            getCodeImg().then(res => {
              this.captchaEnabled = res.captchaEnabled === undefined ? true : res.captchaEnabled;
              if (this.captchaEnabled) {
                this.codeUrl = "data:image/gif;base64," + res.img;
                this.loginForm.uuid = res.uuid; // 表单 token
              }
            });
          },
    	...
    	}
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    从后台获取到的结果是验证码图片的base64编码数据,因此这里img元素指定图片url时加上了data:image/gif;base64,,其中data:image/gif表示数据类型,base64是数据的编码方式,,后面就是图片的编码数据。

    3.2 登录

    login.js登录页面中可以看到登录处理函数

    	handleLogin() {
            this.$refs.loginForm.validate(valid => { // 表单校验
              if (valid) {
                this.loading = true; // 开启等待蒙板
                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 {
                  Cookies.remove("username");
                  Cookies.remove("password");
                  Cookies.remove('rememberMe');
                }
                this.$store.dispatch("Login", this.loginForm).then(() => {
                  // this.redirect = /index 登录成功后跳转到首页
                  this.$router.push({path: this.redirect || "/"}).catch(() => {
                  });
                }).catch(() => {
                  this.loading = false; // 关闭等待蒙板
                  if (this.captchaEnabled) {
                    this.getCode(); // 登录失败刷新验证码
                  }
                });
              }
            });
          }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    表单校验后完了就是提交数据,这里因为不熟悉Vuex所以一开始都看不出来它那个地方发起了登录请求。

     this.$store.dispatch("Login", this.loginForm)
    
    • 1

    上面这个this.$store.dispatchVuex用来做异步提交、发送数据的函数,像这里的有两个参数("Login", this.loginForm),其中Login是一个动作函数的名称,这个动作是在store组件定义的时候写好的,下面好好捋捋。
    首先看创建store实例的时候,我们注册了很多个组件/模块,其中包含了user模块

    const store = new Vuex.Store({
      modules: {
        app,
        dict,
        user,
        tagsView,
        permission,
        settings
      },
      getters
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    来看store组件的目录结构,可以看到每一个组件就是一个js文件,里面定义了各种各样的变量
    在这里插入图片描述
    进入user.js可以看到user对象中定义的actions中定义了很多动作函数,其中一个是Login函数,就是在这个函数里完成了登录表单的提交动作。

     actions: {
        // 登录
        Login({ commit }, userInfo) {
          const username = userInfo.username.trim()
          const password = userInfo.password
          const code = userInfo.code
          const uuid = userInfo.uuid
          return new Promise((resolve, reject) => {
            login(username, password, code, uuid).then(res => {
              setToken(res.token)
              commit('SET_TOKEN', res.token)
              resolve()
            }).catch(error => {
              reject(error)
            })
          })
        },
    
        // 获取用户信息
        GetInfo({ commit, state }) {...},
    
        // 退出系统
        LogOut({ commit, state }) {...},
    
        // 前端 登出
        FedLogOut({ commit }){...}
      }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    拓展1 Vuex.Store的使用

    mutationsactions都是Vuex.Store里定义函数的属性
    比如定义一个store对象的user模块:user对象里分别用了mutations actions两个属性来做函数定义

    const user = {
    	state: {...},
    	 mutations: {...},
    	 actions: {...}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    展开看

    const user = {
    	state: {
        	name: '',
      	},
      	// mutations 定义的函数使用 commit(state,...) 函数触发的第一个参数都是 state 对象,表示整个 state 对象,同步加载
    	mutations: {
        	SET_NAME: (state, name) => {
          		state.name = name // 因为 state 参数是整个 state 对象,所以可以调取到 name 属性进行操作
        	},
    	},
    	// actions 定义的函数使用 dispatch({commit, state},...) 函数触发,函数里的第一个参数是整个 store 对象,异步加载
    	// 因为 {} 为整个store 对象,所以对象里面包含了 commit函数,state属性等,都可以如{commit, state}这样传递调用。
    	actions: {
    	 	Login({ commit }, userInfo) { // 除了代表 store 对象的{}参数,后面一样可以传递需要的其他参数
          		const username = userInfo.username.trim()
          		const password = userInfo.password
          		const code = userInfo.code
          		const uuid = userInfo.uuid
          		return new Promise((resolve, reject) => {
            		login(username, password, code, uuid).then(res => {
              			setToken(res.token)
              			commit('SET_TOKEN', res.token)
              			resolve()
            		}).catch(error => {
              			reject(error)
            		})
          		})
        	},
    		// 退出系统
        	LogOut({ commit, state }) { // 这里只有代表 store 对象的参数,没有其他参数,因此调用的时候不需要传参
         		return new Promise((resolve, reject) => {
            		logout(state.token).then(() => { // 调用 state 对象里的属性
              			commit('SET_NAME', '') // 用 commit 函数调用 mutations 定义的 SET_NAME 函数
              			...
            		}).catch(error => {
              			reject(error)
            		})
          		})
       		},
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41

    mutations定义的函数SET_NAME如下调用this.$store.commit("SET_NAME",name)
    actions定义的函数LogOut如下调用this.$store.dispatch("LogOut")

    拓展2 vue.router组件

    vue.router组件是vue框架的基础组件之一,用来作为vue项目的独立路由。现在的主流前端框架都会实现自己的路由组件,在vue框架中这个组件就是vue.router

    1. main.js中引入router
    import Vue from 'vue'
    import router from './router'
    
    new Vue({
      el: '#app',
      router,
      store,
      render: h => h(App)
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    router组件的具体内容是安装vue-router插件和定义路由映射
    在这里插入图片描述

    1. 展开看路由映射是怎样定义的

      // 公共路由
      export const constantRoutes = [ // 路由映射列表
      {
      	path: '/redirect',    // 自定义路径
      	component: Layout,    // 路径关联的组件
      	hidden: true,		  // 这里属性指是否隐藏侧边栏
      	children: [           // 嵌套子路由
        	{
          	path: '/redirect/:path(.*)',  
          	component: () => import('@/views/redirect') // 路由懒加载,在匹配到这个路径的时候再导入相关组件
        	}
      	]
      },
      {
      	path: '/login',
      	component: () => import('@/views/login'),
      	hidden: true
      },
      // ...
      ]
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      // 动态路由,基于用户权限动态去加载
      export const dynamicRoutes = [ // 路由映射列表
      {
      	path: '/system/user-auth', // 自定义路径
      	component: Layout,         // 路径关联的组件
      	hidden: true,
      	permissions: ['system:user:edit'], // 权限字段列表
      	children: [
        		{
          	path: 'role/:userId(\\d+)', // /system/role-auth/user/userId=
          	component: () => import('@/views/system/user/authRole'),
          	name: 'AuthRole',
          	meta: { title: '分配角色', activeMenu: '/system/user' } // 元数据,用于拓展应用
        		}
      	]
      },
      // ...
      ]
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18

      基本属性
      path: 映射路径
      component: 路径对应的组件,这里导入组件有两种方式,类似饿汉或者懒汉。
      children: 嵌套子路由,定义父路由后面的路由映射

    2. router的前置后置过滤器
      若依在permission.js中对router设置了前置过滤和后置过滤逻辑。
      在这里插入图片描述
      前置过滤用来做登录和角色校验

      router.beforeEach((to, from, next) => {
          NProgress.start();             // 开启加载进度条
          if (getToken()) { // 有 token
              to.meta.title && store.dispatch('settings/setTitle', to.meta.title);  // 目标页面标题
              if (to.path === '/login') { // 如果请求的是登录页
                  next({path: '/'});     // 路由匹配路径 '/'
                  NProgress.done()          // 加载进度条完成
              } else { // 非登录页
                  if (store.getters.roles.length === 0) { // 若没有角色
                      isRelogin.show = true;                // 提示重新登录
                      // 判断当前用户是否已拉取完user_info信息
                      store.dispatch('GetInfo').then(() => {
                          isRelogin.show = false;
                          store.dispatch('GenerateRoutes').then(accessRoutes => {
                              // 根据roles权限生成可访问的路由表
                              router.addRoutes(accessRoutes);  // 动态添加可访问路由表
                              next({...to, replace: true}) // hack方法 确保addRoutes已完成
                          })
                      }).catch(err => {
                          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}`); // 否则全部重定向到登录页
                  NProgress.done() // 完成进度条
              }
          }
      })
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37

      处理函数的三个参数:
      to:进入到哪个路由去
      from:从哪个路由离开
      next:路由的控制器,用来控制下一步操作,常用的有next(true)和next(false)

  • 相关阅读:
    windows自启动,修改注册表的方式
    MAC结合LINUX进行Jmeter进行java代码压力测试
    安卓应用开发中的参数修改保存
    【前端打怪升级日志之CSS篇】position定位
    动态负荷对电力系统摆幅曲线的影响研究(Matlab代码实现)
    全网最详细Gradio教程系列——Gradio简介
    50个Linux常用命令行快捷键(大部分适配Mac OS)
    3.3 log | 474.一和零,518.零钱兑换II,377. 组合总和 Ⅳ,322. 零钱兑换
    基于Qt的UDP通信、TCP文件传输程序的设计与实现——QQ聊天群聊
    2024年深圳杯&东三省数学建模联赛A题论文首发第二种思路
  • 原文地址:https://blog.csdn.net/qq_31856061/article/details/128001975