基于spring security 实现前后端分离项目权限控制

前后端分离的项目,前端有菜单(menu),后端有API(backendApi),一个menu对应的页面有N个API接口来支持,本文介绍如何基于spring security实现前后端的同步权限控制。

实现思路#

还是基于Role来实现,具体的思路是,一个Role拥有多个Menu,一个menu有多个backendApi,其中Role和menu,以及menu和backendApi都是ManyToMany关系。

验证授权也很简单,用户登陆系统时,获取Role关联的Menu,页面访问后端API时,再验证下用户是否有访问API的权限。

domain定义#

我们用JPA来实现,先来定义Role

Copy
public class Role implements Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; /** * 名称 */ @NotNull @ApiModelProperty(value = "名称", required = true) @Column(name = "name", nullable = false) private String name; /** * 备注 */ @ApiModelProperty(value = "备注") @Column(name = "remark") private String remark; @JsonIgnore @ManyToMany @JoinTable( name = "role_menus", joinColumns = {@JoinColumn(name = "role_id", referencedColumnName = "id")}, inverseJoinColumns = {@JoinColumn(name = "menu_id", referencedColumnName = "id")}) @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) @BatchSize(size = 100) private Set<Menu> menus = new HashSet<>(); }

以及Menu:

Copy
public class Menu implements Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "parent_id") private Integer parentId; /** * 文本 */ @ApiModelProperty(value = "文本") @Column(name = "text") private String text; @ApiModelProperty(value = "angular路由") @Column(name = "link") private String link; @ManyToMany @JsonIgnore @JoinTable(name = "backend_api_menus", joinColumns = @JoinColumn(name="menus_id", referencedColumnName="id"), inverseJoinColumns = @JoinColumn(name="backend_apis_id", referencedColumnName="id")) @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) private Set<BackendApi> backendApis = new HashSet<>(); @ManyToMany(mappedBy = "menus") @JsonIgnore private Set<Role> roles = new HashSet<>(); }

最后是BackendApi,区分method(HTTP请求方法)、tag(哪一个Controller)和path(API请求路径):

Copy
public class BackendApi implements Serializable { private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "tag") private String tag; @Column(name = "path") private String path; @Column(name = "method") private String method; @Column(name = "summary") private String summary; @Column(name = "operation_id") private String operationId; @ManyToMany(mappedBy = "backendApis") @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) private Set<Menu> menus = new HashSet<>(); }

管理页面实现#

Menu菜单是业务需求确定的,因此提供CRUD编辑即可。
BackendAPI,可以通过swagger来获取。
前端选择ng-algin,参见Angular 中后台前端解决方案 - Ng Alain 介绍

通过swagger获取BackendAPI#

获取swagger api有多种方法,最简单的就是访问http接口获取json,然后解析,这很简单,这里不赘述,还有一种就是直接调用相关API获取Swagger对象。

查看官方的web代码,可以看到获取数据大概是这样的:

Copy
String groupName = Optional.fromNullable(swaggerGroup).or(Docket.DEFAULT_GROUP_NAME); Documentation documentation = documentationCache.documentationByGroup(groupName); if (documentation == null) { return new ResponseEntity<Json>(HttpStatus.NOT_FOUND); } Swagger swagger = mapper.mapDocumentation(documentation); UriComponents uriComponents = componentsFrom(servletRequest, swagger.getBasePath()); swagger.basePath(Strings.isNullOrEmpty(uriComponents.getPath()) ? "/" : uriComponents.getPath()); if (isNullOrEmpty(swagger.getHost())) { swagger.host(hostName(uriComponents)); } return new ResponseEntity<Json>(jsonSerializer.toJson(swagger), HttpStatus.OK);

其中的documentationCache、environment、mapper等可以直接Autowired获得:

Copy
@Autowired public SwaggerResource( Environment environment, DocumentationCache documentationCache, ServiceModelToSwagger2Mapper mapper, BackendApiRepository backendApiRepository, JsonSerializer jsonSerializer) { this.hostNameOverride = environment.getProperty("springfox.documentation.swagger.v2.host", "DEFAULT"); this.documentationCache = documentationCache; this.mapper = mapper; this.jsonSerializer = jsonSerializer; this.backendApiRepository = backendApiRepository; }

然后我们自动加载就简单了,写一个updateApi接口,读取swagger对象,然后解析成BackendAPI,存储到数据库:

Copy
@RequestMapping( value = "/api/updateApi", method = RequestMethod.GET, produces = { APPLICATION_JSON_VALUE, HAL_MEDIA_TYPE }) @PropertySourcedMapping( value = "${springfox.documentation.swagger.v2.path}", propertyKey = "springfox.documentation.swagger.v2.path") @ResponseBody public ResponseEntity<Json> updateApi( @RequestParam(value = "group", required = false) String swaggerGroup) { // 加载已有的api Map<String,Boolean> apiMap = Maps.newHashMap(); List<BackendApi> apis = backendApiRepository.findAll(); apis.stream().forEach(api->apiMap.put(api.getPath()+api.getMethod(),true)); // 获取swagger String groupName = Optional.fromNullable(swaggerGroup).or(Docket.DEFAULT_GROUP_NAME); Documentation documentation = documentationCache.documentationByGroup(groupName); if (documentation == null) { return new ResponseEntity<Json>(HttpStatus.NOT_FOUND); } Swagger swagger = mapper.mapDocumentation(documentation); // 加载到数据库 for(Map.Entry<String, Path> item : swagger.getPaths().entrySet()){ String path = item.getKey(); Path pathInfo = item.getValue(); createApiIfNeeded(apiMap, path, pathInfo.getGet(), HttpMethod.GET.name()); createApiIfNeeded(apiMap, path, pathInfo.getPost(), HttpMethod.POST.name()); createApiIfNeeded(apiMap, path, pathInfo.getDelete(), HttpMethod.DELETE.name()); createApiIfNeeded(apiMap, path, pathInfo.getPut(), HttpMethod.PUT.name()); } return new ResponseEntity<Json>(HttpStatus.OK); }

其中createApiIfNeeded,先判断下是否存在,不存在的则新增:

Copy
private void createApiIfNeeded(Map<String, Boolean> apiMap, String path, Operation operation, String method) { if(operation==null) { return; } if(!apiMap.containsKey(path+ method)){ apiMap.put(path+ method,true); BackendApi api = new BackendApi(); api.setMethod( method); api.setOperationId(operation.getOperationId()); api.setPath(path); api.setTag(operation.getTags().get(0)); api.setSummary(operation.getSummary()); // 保存 this.backendApiRepository.save(api); } }

最后,做一个简单页面展示即可:

enter description here

菜单管理#

新增和修改页面,可以选择上级菜单,后台API做成按tag分组,可多选即可:

enter description here

列表页面

enter description here

角色管理#

普通的CRUD,最主要的增加一个菜单授权页面,菜单按层级显示即可:

enter description here

认证实现#

管理页面可以做成千奇百样,最核心的还是如何实现认证。

在上一篇文章spring security实现动态配置url权限的两种方法里我们说了,可以自定义FilterInvocationSecurityMetadataSource来实现。

实现FilterInvocationSecurityMetadataSource接口即可,核心是根据FilterInvocation的Request的method和path,获取对应的Role,然后交给RoleVoter去判断是否有权限。

自定义FilterInvocationSecurityMetadataSource#

我们新建一个DaoSecurityMetadataSource实现FilterInvocationSecurityMetadataSource接口,主要看getAttributes方法:

Copy
@Override public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException { FilterInvocation fi = (FilterInvocation) object; List<Role> neededRoles = this.getRequestNeededRoles(fi.getRequest().getMethod(), fi.getRequestUrl()); if (neededRoles != null) { return SecurityConfig.createList(neededRoles.stream().map(role -> role.getName()).collect(Collectors.toList()).toArray(new String[]{})); } // 返回默认配置 return superMetadataSource.getAttributes(object); }

核心是getRequestNeededRoles怎么实现,获取到干净的RequestUrl(去掉参数),然后看是否有对应的backendAPI,如果没有,则有可能该API有path参数,我们可以去掉最后的path,去库里模糊匹配,直到找到。

Copy
public List<Role> getRequestNeededRoles(String method, String path) { String rawPath = path; // remove parameters if(path.indexOf("?")>-1){ path = path.substring(0,path.indexOf("?")); } // /menus/{id} BackendApi api = backendApiRepository.findByPathAndMethod(path, method); if (api == null){ // try fetch by remove last path api = loadFromSimilarApi(method, path, rawPath); } if (api != null && api.getMenus().size() > 0) { return api.getMenus() .stream() .flatMap(menu -> menuRepository.findOneWithRolesById(menu.getId()).getRoles().stream()) .collect(Collectors.toList()); } return null; } private BackendApi loadFromSimilarApi(String method, String path, String rawPath) { if(path.lastIndexOf("/")>-1){ path = path.substring(0,path.lastIndexOf("/")); List<BackendApi> apis = backendApiRepository.findByPathStartsWithAndMethod(path, method); // 如果为空,再去掉一层path while(apis==null){ if(path.lastIndexOf("/")>-1) { path = path.substring(0, path.lastIndexOf("/")); apis = backendApiRepository.findByPathStartsWithAndMethod(path, method); }else{ break; } } if(apis!=null){ for(BackendApi backendApi : apis){ if (antPathMatcher.match(backendApi.getPath(), rawPath)) { return backendApi; } } } } return null; }

其中,BackendApiRepository:

Copy
@EntityGraph(attributePaths = "menus") BackendApi findByPathAndMethod(String path,String method); @EntityGraph(attributePaths = "menus") List<BackendApi> findByPathStartsWithAndMethod(String path,String method);

以及MenuRepository

Copy
@EntityGraph(attributePaths = "roles") Menu findOneWithRolesById(long id);

使用DaoSecurityMetadataSource#

需要注意的是,在DaoSecurityMetadataSource里,不能直接注入Repository,我们可以给DaoSecurityMetadataSource添加一个方法,方便传入:

Copy
public void init(MenuRepository menuRepository, BackendApiRepository backendApiRepository) { this.menuRepository = menuRepository; this.backendApiRepository = backendApiRepository; }

然后建立一个容器,存储实例化的DaoSecurityMetadataSource,我们可以建立如下的ApplicationContext来作为对象容器,存取对象:

Copy
public class ApplicationContext { static Map<Class<?>,Object> beanMap = Maps.newConcurrentMap(); public static <T> T getBean(Class<T> requireType){ return (T) beanMap.get(requireType); } public static void registerBean(Object item){ beanMap.put(item.getClass(),item); } }

在SecurityConfiguration配置中使用DaoSecurityMetadataSource,并通过 ApplicationContext.registerBeanDaoSecurityMetadataSource注册:

Copy
@Override protected void configure(HttpSecurity http) throws Exception { http .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class) .exceptionHandling() .authenticationEntryPoint(problemSupport) .accessDeniedHandler(problemSupport) .... // .withObjectPostProcessor() // 自定义accessDecisionManager .accessDecisionManager(accessDecisionManager()) // 自定义FilterInvocationSecurityMetadataSource .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() { @Override public <O extends FilterSecurityInterceptor> O postProcess( O fsi) { fsi.setSecurityMetadataSource(daoSecurityMetadataSource(fsi.getSecurityMetadataSource())); return fsi; } }) .and() .apply(securityConfigurerAdapter()); } @Bean public DaoSecurityMetadataSource daoSecurityMetadataSource(FilterInvocationSecurityMetadataSource filterInvocationSecurityMetadataSource) { DaoSecurityMetadataSource securityMetadataSource = new DaoSecurityMetadataSource(filterInvocationSecurityMetadataSource); ApplicationContext.registerBean(securityMetadataSource); return securityMetadataSource; }

最后,在程序启动后,通过ApplicationContext.getBean获取到daoSecurityMetadataSource,然后调用init注入Repository

Copy
public static void postInit(){ ApplicationContext .getBean(DaoSecurityMetadataSource.class) .init(applicationContext.getBean(MenuRepository.class),applicationContext.getBean(BackendApiRepository.class)); } static ConfigurableApplicationContext applicationContext; public static void main(String[] args) throws UnknownHostException { SpringApplication app = new SpringApplication(UserCenterApp.class); DefaultProfileUtil.addDefaultProfile(app); applicationContext = app.run(args); // 后初始化 postInit(); }

大功告成!

延伸阅读#


作者:Jadepeng
出处:jqpeng的技术记事本--http://www.cnblogs.com/xiaoqi
您的支持是对博主最大的鼓励,感谢您的认真阅读。
本文版权归作者所有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

关注作者

欢迎关注作者微信公众号, 一起交流软件开发:欢迎关注作者微信公众号

posted @   JadePeng  阅读(60427)  评论(9编辑  收藏  举报
编辑推荐:
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· 没有源码,如何修改代码逻辑?
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
点击右上角即可分享
微信分享提示
CONTENTS