基於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

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:

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請求路徑):

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代碼,可以看到獲取數據大概是這樣的:

        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獲得:

@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,存儲到數據庫:

@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,先判斷下是否存在,不存在的則新增:

 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方法:

     @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,去庫里模糊匹配,直到找到。

 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:

    @EntityGraph(attributePaths = "menus")
    BackendApi findByPathAndMethod(String path,String method);

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

以及MenuRepository

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

使用DaoSecurityMetadataSource

需要注意的是,在DaoSecurityMetadataSource里,不能直接注入Repository,我們可以給DaoSecurityMetadataSource添加一個方法,方便傳入:

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

然后建立一個容器,存儲實例化的DaoSecurityMetadataSource,我們可以建立如下的ApplicationContext來作為對象容器,存取對象:

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注冊:

 @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

 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
您的支持是對博主最大的鼓勵,感謝您的認真閱讀。
本文版權歸作者所有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接,否則保留追究法律責任的權利。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM