本博文是在权限管理模块的前后端都已经搭建完成的基础上,针对其中的bug进行解决的。
1、权限管理流程
(1)前端项目启动
前端项目启动后,会自动访问 http://localhost:9528/ ,但是实际跳转的是http://localhost:9528/login
。
这是由于在src目录下的permission.js中定义了路由跳转之前的判断规则。
// 该操作会在路由跳转之前执行
router.beforeEach(async(to, from, next) => {
// start progress bar
NProgress.start()
// 再路由跳转之前获取用户存储在cookie中token
const hasToken = getToken()
// 已登录,进入这里
if (hasToken) {
if (to.path === '/login') {
// if is logged in, redirect to the home page
next({ path: '/' })
NProgress.done()
} else {
// determine whether the user has obtained his permission roles through getInfo
const hasRoles = store.getters.roles && store.getters.roles.length > 0
if (hasRoles) {
next()
} else {
try {
const { roles } = await store.dispatch('GetInfo')
// generate accessible routes map based on roles
const accessRoutes = await store.dispatch('permission/generateRoutes', roles)
// dynamically add accessible routes
router.addRoutes(accessRoutes)
next({ ...to, replace: true })
} catch (error) {
// remove token and go to login page to re-login
await store.dispatch('FedLogOut')
Message.error(error || 'Has Error')
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
}
}
// 没有登录,进入这里
else {
/* has no token*/
// 验证要跳转的路由地址是否在白名单中
if (whiteList.indexOf(to.path) !== -1) {
// 在白名单中,直接跳转
next()
} else {
// 不在白名单中,跳转到登录页面
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
})
以上的代码,在路由跳转之前进行判断,如果已经登录(判断标准为是否可以获取到token),直接跳转到相关页面。没有登录,跳转到登录页面进行登录。
其中获取token的方法由 import { getToken } from ‘@/utils/auth’ 导入,该js文件中定义了token从cookie中获取。
export function getToken() {
return Cookies.get(TokenKey)
}
(2)没有登录token的执行流程
// 路由跳转的白名单
const whiteList = ['/login', '/auth-redirect']
// 没有登录,进入这里
else {
/* has no token*/
// 验证要跳转的路由地址是否在白名单中
if (whiteList.indexOf(to.path) !== -1) {
// 在白名单中,直接跳转
next()
} else {
// 不在白名单中,跳转到登录页面
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
先判断是否在白名单中,是则跳转。
不是的话,跳转到登录页面。
以上两个步骤都是在获取登录之后的信息,那么是如何登录的呢,页面上定义的跳转到登录页面的代码为:
next(`/login?redirect=${to.path}`)
要跳转的页面为:src/views/login/index.vue,在该页面上有一系列的校验方法去获取正确的表单信息,然后点击登录时的处理方法为;
<el-button :loading="loading" type="primary" style="width:100%;" @click="handleLogin">
登录
</el-button>
<script>
handleLogin() {
// 表单数据校验
this.$refs.loginForm.validate(valid => {
if (valid) {
// debugger
this.loading = true
// 调用函数进行登录
this.$store.dispatch('Login', this.loginForm).then(() => {
this.loading = false
this.$router.push({ path: this.redirect || '/' })
}).catch(() => {
this.loading = false
})
} else {
console.log('error submit!!')
return false
}
})
}
</script>
这里去调用登录的函数时明显使用了很明显用了单一状态管理库vuex,dispatch派发了一个异步动作,具体要调用的函数是:
src/store/module/user.js中定义。
import { login, logout, getInfo } from '@/api/login'
import { getToken, setToken, removeToken } from '@/utils/auth'
const user = {
state: {
token: getToken(),
name: '',
avatar: '',
buttons: [],
roles: []
},
}
actions: {
// 登录
Login({ commit }, userInfo) {
const username = userInfo.username.trim()
return new Promise((resolve, reject) => {
// 调用定义的登录函数
login(username, userInfo.password).then(response => {
// debugger
const data = response.data
setToken(data.token)
commit('SET_TOKEN', data.token)
resolve()
}).catch(error => {
reject(error)
})
})
},
}
}
export default user
该函数调用了src/api/login.js中的函数
// 登录
export function login(username, password) {
// debugger
return request({
url: '/admin/acl/login',
method: 'post',
data: {
username,
password
}
})
}
后端方法,这个方法在service-acl模块中是无法找到的,因为这是springsecurity自己定义的登录界面。
接下来去追溯在common/spring-security中定义的执行逻辑
SpringSecurity的权限管理拦截规则一般都在定义的配置类中定义。
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class TokenWebSecurityConfig extends WebSecurityConfigurerAdapter {
private UserDetailsService userDetailsService;
private TokenManager tokenManager;
private DefaultPasswordEncoder defaultPasswordEncoder;
private RedisTemplate redisTemplate;
@Autowired
public TokenWebSecurityConfig(UserDetailsService userDetailsService, DefaultPasswordEncoder defaultPasswordEncoder,
TokenManager tokenManager, RedisTemplate redisTemplate) {
this.userDetailsService = userDetailsService;
this.defaultPasswordEncoder = defaultPasswordEncoder;
this.tokenManager = tokenManager;
this.redisTemplate = redisTemplate;
}
/**
* 配置设置
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.exceptionHandling()
.authenticationEntryPoint(new UnauthorizedEntryPoint())
.and().csrf().disable()
.authorizeRequests()
.anyRequest().authenticated()
.and().logout().logoutUrl("/admin/acl/index/logout")
.addLogoutHandler(new TokenLogoutHandler(tokenManager,redisTemplate)).and()
.addFilter(new TokenLoginFilter(authenticationManager(), tokenManager, redisTemplate))
.addFilter(new TokenAuthenticationFilter(authenticationManager(), tokenManager, redisTemplate)).httpBasic();
}
/**
* 密码处理
* @param auth
* @throws Exception
*/
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(defaultPasswordEncoder);
}
/**
* 配置哪些请求不拦截
* @param web
* @throws Exception
*/
@Override
public void configure(WebSecurity web) throws Exception {
// web.ignoring().antMatchers("/api/**",
// "/swagger-resources/**", "/webjars/**", "/v2/**", "/swagger-ui.html/**"
// );
//web.ignoring().antMatchers("/*/**");
}
}
注:springSecurity使用的是过滤器Filter而不是拦截器Interceptor,意味着SpringSecurity 能够管理的不仅仅是 SpringMVC 中的 handler 请求,还包含 Web 应用中所有请求。比如: 项目中的静态资源也会被拦截,从而进行权限控制。
配置类中需要注意的点为:
-
@EnableWebSecurity 注解表示启用 Web 安全功能。
-
springsecurity的过滤器链中添加了两个自定义的过滤器类,其中一个是TokenLoginFilter,另一个是TokenAuthenticationFilter,这里重点去跟踪TokenLoginFilter。
TokenLoginFilter:登录过滤器
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter { private AuthenticationManager authenticationManager; private TokenManager tokenManager; private RedisTemplate redisTemplate; public TokenLoginFilter(AuthenticationManager authenticationManager, TokenManager tokenManager, RedisTemplate redisTemplate) { this.authenticationManager = authenticationManager; this.tokenManager = tokenManager; this.redisTemplate = redisTemplate; this.setPostOnly(false); // 定义了登录要访问的地址 this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/admin/acl/login","POST")); } // 登录的处理方法 @Override public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res) throws AuthenticationException { try { // 获取用户信息 User user = new ObjectMapper().readValue(req.getInputStream(), User.class); System.out.println(user); // return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), new ArrayList<>())); } catch (IOException e) { throw new RuntimeException(e); } }
访问登录处理方法时
User user = new ObjectMapper().readValue(req.getInputStream(), User.class);
用来获取用户信息,这里的User和service_acl中entity中的User.java相似,只是部分字段不同。
- service_acl中的User对应的是数据库中的acl_user表
- spring_security中的User.java是spring security要用的。
获取用户登录信息之后,执行下面的代码
return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), new ArrayList<>()));
执行以上代码的时候会去service_acl模块下的UserDetailsServiceImpl.java中获取用户信息和用户权限列表。
@Service("userDetailsService") public class UserDetailsServiceImpl implements UserDetailsService { @Autowired private UserService userService; @Autowired private PermissionService permissionService; // 根据账号获取用户信息 @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 从数据库中取出用户信息 User user=userService.selectByUsername(username); // 判断用户是否存在 if(null==user){ } // 讲师数据库中查询的用户转换为SpringSecurity需要的登录用户类型 com.zjl.security.entity.User curUser = new com.zjl.security.entity.User(); BeanUtils.copyProperties(user,curUser); // 调acl模块下的service获取用户的权限信息 List<String> authorities = permissionService.selectPermissionValueByUserId(user.getId()); // 根据SpringSecurity需要的登录用户和用户的权限信息创建SecurityUser对象 SecurityUser securityUser = new SecurityUser(curUser); securityUser.setPermissionValueList(authorities); return securityUser; } }
获取到用户信息后,登录成功,调用登录过滤器中的登录成功方法,将登录token存入redis中。
@Override protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse res, FilterChain chain, Authentication auth) throws IOException, ServletException { SecurityUser user = (SecurityUser) auth.getPrincipal(); String token = tokenManager.createToken(user.getCurrentUserInfo().getUsername()); // 认证成功,将登陆的用户信息放入redis中 redisTemplate.opsForValue().set(user.getCurrentUserInfo().getUsername(), user.getPermissionValueList()); ResponseUtil.out(res, R.ok().data("token", token)); }
否则登录失败,抛出异常提示。
@Override protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { ResponseUtil.out(response, R.error()); } }
(3)有token时如何根据不同用户获取不同的界面
// 已登录,进入这里
if (hasToken) {
if (to.path === '/login') {
// if is logged in, redirect to the home page
next({ path: '/' })
NProgress.done()
} else {
// determine whether the user has obtained his permission roles through getInfo
const hasRoles = store.getters.roles && store.getters.roles.length > 0
if (hasRoles) {
next()
} else {
try {
const { roles } = await store.dispatch('GetInfo')
// generate accessible routes map based on roles
const accessRoutes = await store.dispatch('permission/generateRoutes', roles)
// dynamically add accessible routes
router.addRoutes(accessRoutes)
next({ ...to, replace: true })
} catch (error) {
// remove token and go to login page to re-login
await store.dispatch('FedLogOut')
Message.error(error || 'Has Error')
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
}
}
上面的代码中,如果有token且要跳转的页面是登录页面的话,直接重定向到首页。否则,先根据用户的token获取角色的权限。
代码中获取角色的方法为:
import store from './store'
const hasRoles = store.getters.roles && store.getters.roles.length > 0
src/store/getters中的方法
const getters = {
sidebar: state => state.app.sidebar,
device: state => state.app.device,
token: state => state.user.token,
avatar: state => state.user.avatar,
name: state => state.user.name,
buttons: state => state.user.buttons,
roles: state => state.user.roles,
routes: state => state.permission.routes,
addRoutes: state => state.permission.addRoutes
}
export default getters
获取用户角色的方法在src/store/module/user.js中的 GetInfo() 方法。
import { login, logout, getInfo } from '@/api/login'
import { getToken, setToken, removeToken } from '@/utils/auth'
const user = {
state: {
token: getToken(),
name: '',
avatar: '',
buttons: [],
roles: []
},
}
actions: {
// 获取用户信息
async GetInfo({ commit, state }) {
return new Promise((resolve, reject) => {
getInfo(state.token).then(response => {
// debugger
const data = response.data
// 从用户信息中获取用户角色
if (data.roles && data.roles.length > 0) { // 验证返回的roles是否是一个非空数组
commit('SET_ROLES', data.roles)
} else {
reject('getInfo: roles must be a non-null array !')
}
const buttonAuthList = []
data.permissionValueList.forEach(button => {
buttonAuthList.push(button)
})
commit('SET_NAME', data.name)
commit('SET_AVATAR', data.avatar)
commit('SET_BUTTONS', buttonAuthList)
resolve(response)
}).catch(error => {
reject(error)
})
})
},
}
export default user
定义的调用后端接口的方法,src/api/login.js中的getInfo() 方法。
import request from '@/utils/request'
// 登录
export function login(username, password) {
// debugger
return request({
url: '/admin/acl/login',
method: 'post',
data: {
username,
password
}
})
}
// 获取用户信息
export function getInfo(token) {
return request({
url: '/admin/acl/index/info',
method: 'get',
params: { token }
})
}
// 登出
export function logout() {
//debugger
return request({
url: '/admin/acl/index/logout',
method: 'post'
})
}
// 获取菜单权限数据
export function getMenu() {
return request({
url: '/admin/acl/index/menu',
method: 'get'
})
}
访问对应的service-acl模块中的controller相关方法之前,会先经过SpringSecurity中定义的访问过滤器。
public class TokenAuthenticationFilter extends BasicAuthenticationFilter {
private TokenManager tokenManager;
private RedisTemplate redisTemplate;
public TokenAuthenticationFilter(AuthenticationManager authManager, TokenManager tokenManager,RedisTemplate redisTemplate) {
super(authManager);
this.tokenManager = tokenManager;
this.redisTemplate = redisTemplate;
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws IOException, ServletException {
logger.info("================="+req.getRequestURI());
// 判断是不是admin,超级管理员用户,如是,直接通过。
if(req.getRequestURI().indexOf("admin") == -1) {
chain.doFilter(req, res);
return;
}
// 不是admin,超级管理员用户
UsernamePasswordAuthenticationToken authentication = null;
try {
// 根据请求获取用户权限
authentication = getAuthentication(req);
} catch (Exception e) {
ResponseUtil.out(res, R.error());
}
// 权限不为空
if (authentication != null) {
// 设置权限
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
ResponseUtil.out(res, R.error());
}
chain.doFilter(req, res);
}
private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
// token置于header里
String token = request.getHeader("token");
if (token != null && !"".equals(token.trim())) {
// 根据token获取用户信息
String userName = tokenManager.getUserFromToken(token);
// 从redis中获取用户信息
List<String> permissionValueList = (List<String>) redisTemplate.opsForValue().get(userName);
// 权限封装
Collection<GrantedAuthority> authorities = new ArrayList<>();
for(String permissionValue : permissionValueList) {
if(StringUtils.isEmpty(permissionValue)) continue;
SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permissionValue);
authorities.add(authority);
}
// 用户存在权限,给用户添加权限并返回。
if (!StringUtils.isEmpty(userName)) {
return new UsernamePasswordAuthenticationToken(userName, token, authorities);
}
return null;
}
return null;
}
}
经过访问过滤器中的方法获取到用户的权限信息后,访问service-acl中的controller方法,获取用户的具体信息,返回给前端,设置到cookie中。
/**
* 根据token获取用户信息
*/
@GetMapping("info")
public R info(){
// 获取当前登录用户用户名
String username = SecurityContextHolder.getContext().getAuthentication().getName();
Map<String, Object> userInfo = indexService.getUserInfo(username);
return R.ok().data(userInfo);
}
(4)总体的权限调用流程图
2、菜单管理不显示
搞清楚上述的权限调用流程之后,使用admin用户登录,登录后的显示效果为:
树形表格并未完全显示,后台的代码并未出现任何问题,前台打印从后端获取的数据有数据,但是树形结构不显示。
其原因是 element ui 的版本不够,所以显示出现问题。
执行下面的代码,更换其版本。
npm uninstall element-ui -S
npm install element-ui -S
更改后的显示效果为:
3、角色的权限不显示
对不同的角色赋予不同的权限时,由于树形控件只选择一个节点时,父类的id不会被传递,而后台展示数据时需要从父节点开始遍历,所以赋值后登录用户无法展示菜单。
要解决树形控件,没全选时,无法获取父级id的问题,需要将 node_modules\element-ui\lib\element-ui.common.js 中TreeStore.prototype.getCheckedNodes方法,将其中的
childNodes.forEach(function (child) {
if ((child.checked || includeHalfChecked && child.indeterminate) && (!leafOnly || leafOnly && child.isLeaf)) {
checkedNodes.push(child.data);
}
改为:
if ((child.checked || child.indeterminate) && (!leafOnly || leafOnly && child.isLeaf)) {
checkedNodes.push(child.data)
}