引言:借助ruoyi-vue
框架学习其对SpringSecurity
框架的运用。若依的前后端分离版本基于SpringSecurity
和JWT
配合Redis
来做用户状态记录.
UsernamePasswordAuthenticationToken
)认证对象UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
SpringScurity
的安全管理器调用authenticate()
方法,传入刚才创建的认证对象进行认授权认证// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager.authenticate(authenticationToken);
在若依框架中,这个安全管理器是在配置类中手动注入容器的
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {/*** 解决 无法直接注入 AuthenticationManager* echoo mark:手动注入认证管理器*/@Bean@Overridepublic AuthenticationManager authenticationManagerBean() throws Exception {return super.authenticationManagerBean();}省略...
}
@EnableGlobalMethodSecurity: 开启方法级安全保护,包含了@Configuration
安全管理器调用authenticate()
方法,会进入UserDetailsServiceImpl.loadUserByUsername()
方法做登录校验操作,这个UserDetailsServiceImpl
是若依实现的,loadUserByUsername()
方法里就是若依自定义的登录逻辑。这里跳过了一些细节,就是如何保证authenticate()
方法用的是若依自定义的登录逻辑?这个是通过重写WebSecurityConfigurerAdapter
这个安全适配器里面的configure()
方法来指定的。
首先可以看到配置类是继承了WebSecurityConfigurerAdapter
这个父类的,然后通过重写configure(AuthenticationManagerBuilder auth)
方法来指定用户详情业务对象userDetailsService
,这个userDetailsService
就是若依自定义的认证业务对象。
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {/** 自定义用户认证逻辑 */@Autowiredprivate UserDetailsService userDetailsService;/** 身份认证接口 */@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());}
}
springframework.security
的UserDetailsService
接口,并通过实现它的loadUserByUsername(String username)
方法自定义认证逻辑。@Service
public class UserDetailsServiceImpl implements UserDetailsService {// echoo mark:登录逻辑@Overridepublic 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);}
}
authenticate(authenticationToken)
方法会去调用UserDetailsService.loadUserByUsername(String username)
方法的具体实现UserDetailsServiceImpl.loadUserByUsername(String username)
去做登录认证。完美!// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager.authenticate(authenticationToken);
// echoo mark:登录逻辑@Overridepublic 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));}
上面是用户认证登录的基本逻辑,后续还有登录状态相关的逻辑。
登录认证后就是关于登录状态的逻辑了。
首先是token
令牌的创建
/*** 创建令牌*/public String createToken(LoginUser loginUser) {String token = IdUtils.fastUUID();loginUser.setToken(token); // 在登录对象里保存一份token数据setUserAgent(loginUser); // 设置用户代理信息refreshToken(loginUser);Map 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());}
User-Agent
是用户代理信息,包含了客户使用的操作系统及版本、CPU 类型、浏览器及版本、浏览器渲染引擎、浏览器语言、浏览器插件等信息
生成jwt
令牌:
/*** 从数据声明生成令牌* @param claims 数据声明* @return 令牌*/private String createToken(Map 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;}
其中signWith()
方法用来配置jwt
生成token
时用的算法和密钥,然后调用compact()
来打包压缩生成一个jwt
专用token
@Overridepublic 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 实例配置算法和密钥}
经过加密算法加密后的token
:
eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6IjdlMzczNDFlLTJlNjQtNDRkZC1hMTU1LTkyMTE0NDQ2NzBjMyJ9.rBblkvEk81768K0tTj0FCaApvqIwFHGKoHxXiZXTJiGcdqhq8gbbzFMwdG-h4FCVFMsTjHSPZe3Dr5at0jqv6g
这个token
包含了登录用户的登录参数,如登录用户唯一的uuid
,在后续请求中将会用到。
页面元素,验证码以img
元素展示。点击触发getCode()
方法获取后台验证码。
这里的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}});},...}
从后台获取到的结果是验证码图片的
base64编码数据
,因此这里img
元素指定图片url
时加上了data:image/gif;base64,
,其中data:image/gif
表示数据类型,base64
是数据的编码方式,,
后面就是图片的编码数据。
在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(); // 登录失败刷新验证码}});}});}
表单校验后完了就是提交数据,这里因为不熟悉Vuex
所以一开始都看不出来它那个地方发起了登录请求。
this.$store.dispatch("Login", this.loginForm)
上面这个this.$store.dispatch
是Vuex
用来做异步提交、发送数据的函数,像这里的有两个参数("Login", this.loginForm)
,其中Login
是一个动作函数的名称,这个动作是在store
组件定义的时候写好的,下面好好捋捋。
首先看创建store
实例的时候,我们注册了很多个组件/模块,其中包含了user
模块
const store = new Vuex.Store({modules: {app,dict,user,tagsView,permission,settings},getters
})
来看store
组件的目录结构,可以看到每一个组件就是一个js
文件,里面定义了各种各样的变量
进入user.js
可以看到user
对象中定义的actions
中定义了很多动作函数,其中一个是Login
函数,就是在这个函数里完成了登录表单的提交动作。
actions: {// 登录Login({ commit }, userInfo) {const username = userInfo.username.trim()const password = userInfo.passwordconst code = userInfo.codeconst uuid = userInfo.uuidreturn 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 }){...}}
mutations
和actions
都是Vuex.Store里定义函数的属性
比如定义一个store
对象的user
模块:user
对象里分别用了mutations
actions
两个属性来做函数定义
const user = {state: {...},mutations: {...},actions: {...}
}
展开看
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.passwordconst code = userInfo.codeconst uuid = userInfo.uuidreturn 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)})})},}
}
mutations
定义的函数SET_NAME
如下调用this.$store.commit("SET_NAME",name)
actions
定义的函数LogOut
如下调用this.$store.dispatch("LogOut")