当前后端分离时,权限问题的处理也和我们传统的处理方式有一点差异.笔者前几天刚好在负责一个项目的权限管理模块,现在权限管理模块已经做完了,我想通过 5-6 篇文章,来介绍一下项目中遇到的问题以及我的解决方案,希望这个系列能够给小伙伴一些帮助.本系列文章并不是手把手的教程,主要介绍了核心思路并讲解了核心代码,完整的代码小伙伴们可以在 GitHub 上 star 并 clone 下来研究.另外,原本计划把项目跑起来放到网上供小伙伴们查看,但是之前买服务器为了省钱,内存只有 512M,两个应用跑不起来 (已经有一个 V 部落开源项目 在运行),因此小伙伴们只能将就看一下下面的截图了,GitHub 上有部署教程,部署到本地也可以查看完整效果.
项目地址: https://github.com/lenve/vhr
上篇文章我们对项目做了一个整体的介绍,从本文开始,我们就来实现我们的权限管理模块.由于前后端分离,因此我们先来完成后台接口,完成之后,可以先用 POSTMAN 或者 RESTClient 等工具进行测试,测试成功之后,我们再来着手开发前端.
本文是本系列的第二篇,建议先阅读前面的文章有助于更好的理解本文:
1. SpringBoot+vue 前后端分离,使用 SpringSecurity 完美处理权限问题 (一)
创建 SpringBoot 项目
在 IDEA 中创建 SpringBoot 项目,创建完成之后,添加如下依赖:
这些都是常规的依赖,有 SpringBoot,SpringSecurity,Druid 数据库连接池,还有数据库驱动.
<dependencies>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>1.3.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.0.29</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
</dependencies>
然后在 application.properties 中配置数据库,如下:
OK,至此,我们的工程就创建好了.
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/vhr?useUnicode=true&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=123
server.port=8082
创建 Hr 和 HrService
首先我们需要创建 Hr 类,即我们的用户类,该类实现了 UserDetails 接口,该类的属性如下:
如果小伙伴对属性的含义有疑问,可以参考 1. 权限数据库设计 .
public class Hr implements UserDetails {
private Long id;
private String name;
private String phone;
private String telephone;
private String address;
private boolean enabled;
private String username;
private String password;
private String remark;
private List < Role > roles;
private String userface;
//getter/setter省略
}
UserDetails 接口默认有几个方法需要实现,这几个方法中,除了 isEnabled 返回了正常的 enabled 之外,其他的方法我都统一返回 true,因为我这里的业务逻辑并不涉及到账户的锁定,密码的过期等等,只有账户是否被禁用,因此只处理了 isEnabled 方法,这一块小伙伴可以根据自己的实际情况来调整.另外,UserDetails 中还有一个方法叫做 getAuthorities,该方法用来获取当前用户所具有的角色,但是小伙伴也看到了,我的 Hr 中有一个 roles 属性用来描述当前用户的角色,因此我的 getAuthorities 方法的实现如下:
即直接从 roles 中获取当前用户所具有的角色,构造 SimpleGrantedAuthority 然后返回即可.
public Collection < ?extends GrantedAuthority > getAuthorities() {
List < GrantedAuthority > authorities = new ArrayList < >();
for (Role role: roles) {
authorities.add(new SimpleGrantedAuthority(role.getName()));
}
return authorities;
}
创建好 Hr 之后,接下来我们需要创建 HrService,用来执行登录等操作,HrService 需要实现 UserDetailsService 接口,如下:
这里最主要是实现了 UserDetailsService 接口中的 loadUserByUsername 方法,在执行登录的过程中,这个方法将根据用户名去查找用户,如果用户不存在,则抛出 UsernameNotFoundException 异常,否则直接将查到的 Hr 返回.HrMapper 用来执行数据库的查询操作,这个不在本系列的介绍范围内,所有涉及到数据库的操作都将只介绍方法的作用.
@Service
@Transactional
public class HrService implements UserDetailsService {
@Autowired
HrMapper hrMapper;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
Hr hr = hrMapper.loadUserByUsername(s);
if (hr == null) {
throw new UsernameNotFoundException("用户名不对");
}
return hr;
}
}
自定义 FilterInvocationSecurityMetadataSource
FilterInvocationSecurityMetadataSource 有一个默认的实现类 DefaultFilterInvocationSecurityMetadataSource,该类的主要功能就是通过当前的请求地址,获取该地址需要的用户角色,我们照猫画虎,自己也定义一个 FilterInvocationSecurityMetadataSource,如下:
关于自定义这个类,我说如下几点:
@Component
public class UrlFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
@Autowired
MenuService menuService;
AntPathMatcher antPathMatcher = new AntPathMatcher();
@Override
public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
//获取请求地址
String requestUrl = ((FilterInvocation) o).getRequestUrl();
if ("/login_p".equals(requestUrl)) {
return null;
}
List<Menu> allMenu = menuService.getAllMenu();
for (Menu menu : allMenu) {
if (antPathMatcher.match(menu.getUrl(), requestUrl)&&menu.getRoles().size()>0) {
List<Role> roles = menu.getRoles();
int size = roles.size();
String[] values = new String[size];
for (int i = 0; i < size; i++) {
values[i] = roles.get(i).getName();
}
return SecurityConfig.createList(values);
}
}
//没有匹配上的资源,都是登录访问
return SecurityConfig.createList("ROLE_LOGIN");
}
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
return null;
}
@Override
public boolean supports(Class<?> aClass) {
return FilterInvocation.class.isAssignableFrom(aClass);
}
}
1. 一开始注入了 MenuService,MenuService 的作用是用来查询数据库中 url pattern 和 role 的对应关系,查询结果是一个 List 集合,集合中是 Menu 类,Menu 类有两个核心属性,一个是 url pattern,即匹配规则 (比如 / admin/**),还有一个是 List, 即这种规则的路径需要哪些角色才能访问.
2. 我们可以从 getAttributes(Object o) 方法的参数 o 中提取出当前的请求 url,然后将这个请求 url 和数据库中查询出来的所有 url pattern 一一对照,看符合哪一个 url pattern,然后就获取到该 url pattern 所对应的角色,当然这个角色可能有多个,所以遍历角色,最后利用 SecurityConfig.createList 方法来创建一个角色集合.
3. 第二步的操作中,涉及到一个优先级问题,比如我的地址是
/employee/basic / hello
, 这个地址既能被 / employee/** 匹配,也能被 / employee/basic/** 匹配,这就要求我们从数据库查询的时候对数据进行排序,将 / employee/basic/** 类型的 url pattern 放在集合的前面去比较.
4. 如果 getAttributes(Object o)方法返回 null 的话,意味着当前这个请求不需要任何角色就能访问,甚至不需要登录.但是在我的整个业务中,并不存在这样的请求,我这里的要求是,所有未匹配到的路径,都是认证 (登录) 后可访问,因此我在这里返回一个 ROLE_LOGIN 的角色,这种角色在我的角色数据库中并不存在,因此我将在下一步的角色比对过程中特殊处理这种角色.
5. 如果地址是 / login_p,这个是登录页,不需要任何角色即可访问,直接返回 null.
6.getAttributes(Object o) 方法返回的集合最终会来到 AccessDecisionManager 类中,接下来我们再来看 AccessDecisionManager 类.
自定义 AccessDecisionManager
自定义 UrlAccessDecisionManager 类实现 AccessDecisionManager 接口,如下:
关于这个类,我说如下几点:
@Component
public class UrlAccessDecisionManager implements AccessDecisionManager {
@Override
public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, AuthenticationException {
Iterator<ConfigAttribute> iterator = collection.iterator();
while (iterator.hasNext()) {
ConfigAttribute ca = iterator.next();
//当前请求需要的权限
String needRole = ca.getAttribute();
if ("ROLE_LOGIN".equals(needRole)) {
if (authentication instanceof AnonymousAuthenticationToken) {
throw new BadCredentialsException("未登录");
} else
return;
}
//当前用户所具有的权限
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
for (GrantedAuthority authority : authorities) {
if (authority.getAuthority().equals(needRole)) {
return;
}
}
}
throw new AccessDeniedException("权限不足!");
}
@Override
public boolean supports(ConfigAttribute configAttribute) {
return true;
}
@Override
public boolean supports(Class<?> aClass) {
return true;
}
}
1.decide 方法接收三个参数,其中第一个参数中保存了当前登录用户的角色信息,第三个参数则是 UrlFilterInvocationSecurityMetadataSource 中的 getAttributes 方法传来的,表示当前请求需要的角色(可能有多个).
2. 如果当前请求需要的权限为 ROLE_LOGIN 则表示登录即可访问,和角色没有关系,此时我需要判断 authentication 是不是 AnonymousAuthenticationToken 的一个实例,如果是,则表示当前用户没有登录,没有登录就抛一个 BadCredentialsException 异常,登录了就直接返回,则这个请求将被成功执行.
3. 遍历 collection,同时查看当前用户的角色列表中是否具备需要的权限,如果具备就直接返回,否则就抛异常.
4. 这里涉及到一个 all 和 any 的问题:假设当前用户具备角色 A,角色 B,当前请求需要角色 B,角色 C,那么是要当前用户要包含所有请求角色才算授权成功还是只要包含一个就算授权成功?我这里采用了第二种方案,即只要包含一个即可.小伙伴可根据自己的实际情况调整 decide 方法中的逻辑.
自定义 AccessDeniedHandler
通过自定义 AccessDeniedHandler 我们可以自定义 403 响应的内容,如下:
out.write("{\"status\":\"error\",\"msg\":\"权限不足,请联系管理员!\"}");
@Component
public class AuthenticationAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest httpServletRequest, HttpServletResponse resp, AccessDeniedException e) throws IOException, ServletException {
resp.setStatus(HttpServletResponse.SC_FORBIDDEN);
resp.setCharacterEncoding("UTF-8");
PrintWriter out = resp.getWriter();
配置 WebSecurityConfig
out.flush();
out.close();
}
}
最后在 webSecurityConfig 中完成简单的配置即可,如下:
sb.append("{\"status\":\"error\",\"msg\":\"");
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
HrService hrService;
@Autowired
UrlFilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource;
@Autowired
UrlAccessDecisionManager urlAccessDecisionManager;
@Autowired
AuthenticationAccessDeniedHandler authenticationAccessDeniedHandler;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(hrService);
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/index.html", "/static/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(O o) {
o.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource);
o.setAccessDecisionManager(urlAccessDecisionManager);
return o;
}
}).and().formLogin().loginPage("/login_p").loginProcessingUrl("/login").usernameParameter("username").passwordParameter("password").permitAll().failureHandler(new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
httpServletResponse.setContentType("application/json;charset=utf-8");
PrintWriter out = httpServletResponse.getWriter();
StringBuffer sb = new StringBuffer();
if (e instanceof UsernameNotFoundException || e instanceof BadCredentialsException) {
sb.append("用户名或密码输入错误,登录失败!");
} else if (e instanceof DisabledException) {
sb.append("账户被禁用,登录失败,请联系管理员!");
} else {
sb.append("登录失败!");
}
sb.append("\"}");
关于这个配置,我说如下几点:
out.write(sb.toString());
out.flush();
out.close();
}
}).successHandler(new AuthenticationSuccessHandler() {@Override public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException,
ServletException {
httpServletResponse.setContentType("application/json;charset=utf-8");
PrintWriter out = httpServletResponse.getWriter();
ObjectMapper objectMapper = new ObjectMapper();
String s = "{\"status\":\"success\",\"msg\":" + objectMapper.writeValueAsString(HrUtils.getCurrentHr()) + "}";
out.write(s);
out.flush();
out.close();
}
}).and().logout().permitAll().and().csrf().disable().exceptionHandling().accessDeniedHandler(authenticationAccessDeniedHandler);
}
}
1. 在 configure(HttpSecurity http) 方法中,通过 withObjectPostProcessor 将刚刚创建的 UrlFilterInvocationSecurityMetadataSource 和 UrlAccessDecisionManager 注入进来.到时候,请求都会经过刚才的过滤器(除了 configure(WebSecurity web) 方法忽略的请求).
2.successHandler 中配置登录成功时返回的 JSON,登录成功时返回当前用户的信息.
3.failureHandler 表示登录失败,登录失败的原因可能有多种,我们根据不同的异常输出不同的错误提示即可.
OK,这些操作都完成之后,我们可以通过 POSTMAN 或者 RESTClient 来发起一个登录请求,看到如下结果则表示登录成功:
来源: https://segmentfault.com/a/1190000012763317