前后端分离 springboot+springSecurity+
对于权限的设计可以分为菜单权限和数据权限两个部分
菜单权限
数据库表结构
关于这个表,我说如下几点:
1.hr表是用户表,存放了用户的基本信息。
2.role是角色表,name字段表示角色的英文名称,按照SpringSecurity的规范,将以ROLE_开始,nameZh字段表示角色的中文名称。
3.menu表是一个资源表,该表涉及到的字段有点多,由于我的前端采用了Vue来做,因此当用户登录成功之后,系统将根据用户的角色动态加载需要的模块,所有模块的信息将保存在menu表中,menu表中的path、component、iconCls、keepAlive、requireAuth等字段都是Vue-Router中需要的字段,也就是说menu中的数据到时候会以json的形式返回给前端,再由vue动态更新router,menu中还有一个字段url,表示一个url pattern,即路径匹配规则,假设有一个路径匹配规则为/admin/,那么当用户在客户端发起一个/admin/user的请求,将被/admin/拦截到,系统再去查看这个规则对应的角色是哪些,然后再去查看该用户是否具备相应的角色,进而判断该请求是否合法。
实体类
实体类采用多对多的关联关系
@Table(name = "user")
@Entity
public class User implements Serializable{
private static final long serialVersionUID = 1L;
/**
* ID
*/
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "id")
private Integer id;
//用户角色
@ManyToMany(cascade=CascadeType.REFRESH)
@JoinTable(name="sys_role_user",
inverseJoinColumns=@JoinColumn(name="role_id"),
joinColumns=@JoinColumn(name="id"))
private List<SysRole> sysRoles;
/**
*实体类角色
* @AUTO
*/
@Entity(name = "sys_role")
public class SysRole implements Serializable {
private static final long serialVersionUID = 4395377670162987328L;
// 角色ID
@Id
@Column(name="role_id",updatable=false)
private String roleId;
// 上级ID
@Column(name="parent_id")
private String parentId;
// 角色名称
@Column(name="role_name")
private String roleName;
// 角色描述
@Column(name="role_desc")
private String roleDesc;
// 权限标识
@Column(name="permission")
private String permission;
//角色相关用户
@ManyToMany(cascade=CascadeType.REFRESH,mappedBy="sysRoles",fetch = FetchType.LAZY)
private List<User> users;
//角色相关权限
@ManyToMany(cascade=CascadeType.REFRESH)
@JoinTable(name="sys_role_module",
inverseJoinColumns=@JoinColumn(name="module_id"),
joinColumns=@JoinColumn(name="role_id"))
private List<SysModule> sysModules;
/**
*实体类模块
* @AUTO
*/
@Entity(name = "sys_module")
public class SysModule implements Serializable {
private static final long serialVersionUID = 2416163897089599596L;
// 模块ID
@Id
@Column(name="module_id",updatable=false)
private String moduleId;
// 模块名称
@Column(name="module_name")
private String moduleName;
// 链接
@Column(name="url")
private String url;
// 父节点ID
@Column(name="parent_id")
private String parentId;
// 状态
@Column(name="status")
private Integer status;
// 类型
@Column(name="type")
private Integer type;
//菜单相关角色
@ManyToMany(cascade=CascadeType.REFRESH,mappedBy="sysModules",fetch = FetchType.LAZY)
private List<SysRole> roles;
security相关接口实现
UserDetails接口默认有几个方法需要实现,这几个方法中,除了isEnabled返回了正常的enabled之外,其他的方法我都统一返回true,因为我这里的业务逻辑并不涉及到账户的锁定、密码的过期等等,只有账户是否被禁用,因此只处理了isEnabled方法,这一块小伙伴可以根据自己的实际情况来调整。另外,UserDetails中还有一个方法叫做getAuthorities,该方法用来获取当前用户所具有的角色,但是小伙伴也看到了,我的Hr中有一个roles属性用来描述当前用户的角色,因此我的getAuthorities方法的实现如下:
/**
* UserRole定义类
*
*
*
*/
@Component
public class UserRole implements Serializable {
private static final long serialVersionUID = -6908168167010323563L;
@Autowired
private UserService sysUserService;
/**
* 登录用户
*/
String loginUserId;
/**
* 用户密码
*/
String password;
/**
* 用户角色
*/
List<String> role;
public UserRole(){
}
public void setUser(String userName){
sysUserService.getUserRole(this, userName);
}
public String getLoginUserId() {
return loginUserId;
}
public void setLoginUserId(String loginUserId) {
this.loginUserId = loginUserId;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public List<String> getRole() {
return role;
}
public String[] getRoleStringArray(){
String [] rslt = new String[role.size()];
new ArrayList<String>(role).toArray(rslt);
return rslt;
}
public void setRole(List<String> role) {
this.role = role;
}
}
这里最主要是实现了UserDetailsService接口中的loadUserByUsername方法,在执行登录的过程中,这个方法将根据用户名去查找用户,如果用户不存在,则抛出UsernameNotFoundException异常,否则直接将查到的Hr返回。HrMapper用来执行数据库的查询操作,这个不在本系列的介绍范围内,所有涉及到数据库的操作都将只介绍方法的作用。
/**
* UserDetailService实现类
*
*
*
*/
@Component
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserRole user;
@Override
public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
user.setUser(userName);
return new LoginUser(user);
}
}
/**
* User实体类
* 加密等方法实现以外不能修正
*
*
*
*/
class LoginUser
extends org.springframework.security.core.userdetails.User {
private static final long serialVersionUID = -5628332201956992681L;
private final UserRole userRole;
public LoginUser(UserRole user) {
super(user.getLoginUserId(), user.getPassword(),
AuthorityUtils.createAuthorityList(user.getRoleStringArray()));
userRole = user;
}
public UserRole getUser() {
return userRole;
}
}
自定义FilterInvocationSecurityMetadataSource
FilterInvocationSecurityMetadataSource有一个默认的实现类DefaultFilterInvocationSecurityMetadataSource,该类的主要功能就是通过当前的请求地址,获取该地址需要的用户角色,我们照猫画虎,自己也定义一个FilterInvocationSecurityMetadataSource,如下:
@Component
public class UrlFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
@Autowired
SysModuleService menuService;
AntPathMatcher antPathMatcher = new AntPathMatcher();
@Override
public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
//获取请求地址
String requestUrl = ((FilterInvocation) o).getRequestUrl();
if ("/login".equals(requestUrl)) {
return SecurityConfig.createList("ROLE_LOGIN");
}else{
String[] values = new String[1];
values[0] = requestUrl;
return SecurityConfig.createList(values);
}
}
@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)) {
return;
}else{
if (authentication instanceof AnonymousAuthenticationToken) {
throw new BadCredentialsException("未登录");
} else{
//当前用户所具有的权限
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响应的内容,如下:
用户登录失败返回信息
@Component
public class UserAuthenticationFailureHandler implements 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();
sb.append("{\"status\":\"error\",\"msg\":\"");
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();
}
异常拦截
@Component
public class UnauthorizedEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
if(isAjaxRequest(request)){
response.sendError(HttpServletResponse.SC_UNAUTHORIZED,authException.getMessage());
}else{
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
PrintWriter out = response.getWriter();
out.write("{\"status\":\"error\",\"msg\":\""+authException.getMessage()+"\"}");
out.flush();
out.close();
}
}
public static boolean isAjaxRequest(HttpServletRequest request) {
String ajaxFlag = request.getHeader("X-Requested-With");
return ajaxFlag != null && "XMLHttpRequest".equals(ajaxFlag);
}
}
登陆成功拦截器
@Component
public class LoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
// @Autowired
// SysLogService sysLogService;
@Autowired
UserService sysUserS;
@Autowired
SysGovService sysGovS;
private static final String UNKNOWN = "unknown";
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
// 获得授权后可得到用户信息 可使用SUserService进行数据库操作
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
// SysLog sysLog = new SysLog();
// sysLog.setLogModule("1");
// sysLog.setActionType("1");
// sysLog.setLogAction("登录");
// sysLogService.saveLog(sysLog,request);
//获取用户信息
User sysUser = sysUserS.findByLoginName(userDetails.getUsername());
// List<SysRole> rolos = sysUser.getSysRoles();
// List<String> rololist = new ArrayList<>();
// for(SysRole sysRole:rolos){
// rololist.add(sysRole.getPermission());
// }
//页面权限控制
// request.getSession().setAttribute("auditType", sysUserS.getAllRole(sysUser.getUserId()));
String orgId = sysUser.getDeptId();
request.getSession().setAttribute("userId", sysUser.getId());
request.getSession().setAttribute("userName", sysUser.getUserName());
//该用户所属单位ID
List<String> sysgovIds = new ArrayList<>();
if(orgId!=null&&orgId!=""){
request.getSession().setAttribute("userGovId", orgId);
SysGov sysgov = sysGovS.findBySysGovId(orgId);
//获取所有下属机构信息
List<SysGov> sysgovs = sysGovS.recursionFindByParentIdAndStatus(new ArrayList<SysGov>(), orgId);
for (SysGov sysGov2 : sysgovs) {
sysgovIds.add(sysGov2.getGovId());
}
request.getSession().setAttribute("sysgovIds", sysgovIds);
request.getSession().setAttribute("userGovCname", sysgov.getGovCnameall());
request.getSession().setAttribute("userGovCname2", sysgov.getGovCname());
}else{
request.getSession().setAttribute("userGovId", "jusfoun");
request.getSession().setAttribute("sysgovIds", "jusfoun");
request.getSession().setAttribute("userGovCname", "jusfoun");
}
response.setContentType("application/json;charset=utf-8");
response.setHeader("Access-Control-Allow-Origin", "*");
PrintWriter out = response.getWriter();
ObjectMapper objectMapper = new ObjectMapper();
@SuppressWarnings("unchecked")
Map<String,String> map=objectMapper.readValue(objectMapper.writeValueAsString(SecurityContextHolder.getContext().getAuthentication().getPrincipal()),Map.class);
map.remove("user");
map.remove("password");
String s = "{\"status\":\"success\",\"msg\":" + objectMapper.writeValueAsString(map) + "}";
out.write(s);
out.flush();
out.close();
super.onAuthenticationSuccess(request, response, authentication);
}
配置WebSecurityConfig
最后在webSecurityConfig中完成简单的配置即可,如下:
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private static final String ADMIN_AUTH = "ROLE_ADMIN";
@Autowired
UrlFilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource;
@Autowired
UrlAccessDecisionManager urlAccessDecisionManager;
@Autowired
UnauthorizedEntryPoint unauthorizedEntryPoint;
@Autowired
UserAuthenticationFailureHandler userAuthenticationFailureHandler;
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth
.inMemoryAuthentication()
.withUser("user").password("password").roles("USER");
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers(
"/images/**",
"/image/**",
"/**/*.js",
"/**/*.css",
"/resource/img/**",
"/styleResource/img/**","/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.exceptionHandling().authenticationEntryPoint(unauthorizedEntryPoint).and()
.csrf().disable()
.authorizeRequests().withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(O o) {
o.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource);
o.setAccessDecisionManager(urlAccessDecisionManager);
return o;
}
})
.antMatchers("/","/js/*", "/img/*").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
// .loginPage("/login").usernameParameter("username").passwordParameter("password")
// .permitAll()
.failureHandler(userAuthenticationFailureHandler)
.successHandler(loginSuccessHandler())
.and()
.rememberMe()
.tokenValiditySeconds(3600)
.key("mykey");
}
@Configuration
protected static class AuthenticationConfiguration
extends GlobalAuthenticationConfigurerAdapter {
@Autowired
UserDetailsService userDetailsService;
@Override
public void init(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService);
}
}
@Bean
public LoginSuccessHandler loginSuccessHandler(){
LoginSuccessHandler lsh = new LoginSuccessHandler();
lsh.setDefaultTargetUrl("/index");
return lsh;
}
关于这个配置,我说如下几点:
1.在configure(HttpSecurity http)方法中,通过withObjectPostProcessor将刚刚创建的UrlFilterInvocationSecurityMetadataSource和UrlAccessDecisionManager注入进来。到时候,请求都会经过刚才的过滤器(除了configure(WebSecurity web)方法忽略的请求)。
2.successHandler中配置登录成功时返回的JSON,登录成功时返回当前用户的信息。
3.failureHandler表示登录失败,登录失败的原因可能有多种,我们根据不同的异常输出不同的错误提示即可。
OK,这些操作都完成之后,我们可以通过POSTMAN或者RESTClient来发起一个登录请求,看到如下结果则表示登录成功:
关于数据权限的设计
在每个表中加入所属部门的id
在执行查询时加入当前登陆用户的组织机构作为参数
@SuppressWarnings("unchecked")
List<String> sysgovIds = (List<String>) request.getSession().getAttribute("sysgovIds");