上一篇文章 spring boot 实现 jwt 认证,介绍了 spring boot 实现 jwt 认证的流程,本文将关注架构安全性的另一个重要概念——授权,也就是权限控制。
rbac 模型
权限控制有不同的模型,常用的一种是 rbac。rbac 是基于角色的访问控制(role-based access control)的缩写。
简单来讲,rbac 模型大致结构为:
用户(user)-> 角色(role)-> 权限(permission)-> 资源(resource)
用户拥有角色,角色被赋予权限,权限关联资源。同一个用户可能拥有多个角色,同一个角色也可能被赋予多个权限。访问资源需要一种或多种权限。用户访问资源时,对比用户拥有的权限和资源需要的权限。
spring security 支持 rbac 模型,并做了一些简化,将角色和权限合并为 authority。在资源端,角色是 authority,隶属角色的权限也是 authority,检查权限就是在需要的权限和用户拥有的 authority 之间做对比。
具体实现上,spring security 的安全上下文保存了用户信息(userdetails
),用户信息中包含了用户拥有的权限(grantedauthority
)。在 spring security 体系中,userdetailsservice
接口的 loaduserbyusername
方法用于从数据库获取 userdetails 信息,也包括用户拥有的权限信息。
public interface userdetails extends serializable { collection<? extends grantedauthority> getauthorities(); // 省略其他方法 } public interface userdetailsservice { userdetails loaduserbyusername(string username) throws usernamenotfoundexception; }
依托 grantedauthority 抽象,spirng security 的权限控制分为两部分:
- 用户权限管理,由
userdetailsservice
接口的loaduserbyusername
方法实现。获取 userdetails 时,实现 getauthorities() 方法获取权限。至于权限从何而来,自己实现。通常存储在数据库中。 - 资源权限控制,为资源分配需要的权限。
访问资源时,spring security 会调用 authorizationmanager
接口的 check
方法检查权限,对比需要的权限和用户拥有的权限。
实现用户权限管理
用户权限管理,实现权限-角色-用户的层级结构。通常是关系型数据的多对多关系表。
class user { private long id; private string username; private string password; private set<role> roles; } class role { private long id; private string name; private set<permission> permissions; } class permission { private long id; private string code; }
总共需要三张实体表,外加两张关系表。
实现 userdetailsservice
接口的 loaduserbyusername
方法。
@service @requiredargsconstructor public class userdetailsserviceimpl implements userdetailsservice { private final userrepository userrepository; @override public userdetails loaduserbyusername(string username) throws usernamenotfoundexception { user user = userrepository.selectbyusername(username); if (user == null) { throw new usernamenotfoundexception("user not found"); } user.setauthorities(getauthorities()); return user; } private set<simplegrantedauthority> getauthorities(user user) { set<string> authorities = new hashset<>(); for (role role : user.getroles()) { authorities.add("role_" + role.getcode()); for (permission permission : role.getpermissions()) { authorities.add(permission.getcode()); } } return authorities.stream().map(simplegrantedauthority::new).collect(collectors.toset()); } }
spring security 处理角色时会自动加 role_
前缀,在处理 authority 时不会,所以定义角色时,角色名不加 role_ 前缀。
实现资源权限控制
这部分讲述如何为资源指定需要的权限。
先理解什么是资源。资源可以有不同的粒度,比如方法、类、模块、系统等。但对后端应用而言,最直观的划分是 api,一个 api 就是一个资源,访问 api 需要权限。这也是 spring security 的默认粒度。
api 与 controller 的方法存在一一对应的关系,因此,为 api 指定权限,也包括为 controller 的方法指定权限。spring security 可以直接为 api 指定权限,也可以基于注解为 controller 的方法指定权限。
直接为 api 指定权限
直接为 api 指定权限,通过在配置类定义 securityfilterchain
来指定。
@enablewebsecurity // 用于支持 securityfilterchain @configuration public class securityconfig { @bean securityfilterchain securityfilterchain(httpsecurity http) throws exception { return http .authorizehttprequests(auth -> { auth.requestmatchers("/api/auth/**").permitall() .requestmatchers("/api/public/**").permitall() .requestmatchers("/api/admin/**").hasrole("admin") .requestmatchers("/api/users/edit").hasauthority("user:edit") .anyrequest().authenticated(); }) .build(); } }
对以 /api/auth
为前缀的 api 和以 /api/public
为前缀的 api,不进行权限检查。对以 /api/admin
为前缀的 api,需要用户具有 admin 角色。对 /api/users/edit
api,需要用户具有 user:edit 权限。其他 api 需要认证,不需要额外权限。
在 securityfilterchain 中,通常进行比较粗粒度的权限控制,比如以前缀来指定 api 权限。如果进行细粒度的权限控制,如果 api 很多,配置会非常繁琐,也不便于维护。此时,可以基于注解来指定 api 权限。
基于注解指定 api 权限
在 controller 的方法上添加注解,可以间接地为 api 指定权限。
有多种注解支持在方法上控制权限,大致可以分为三类:
- spring security 内置注解,@preauthorize、@postauthorize、@prefilter、@postfilter
- 基于 jsr-250 规范的注解,@rolesallowed、@permitall、@denyall
- spring security 的遗留注解 @secured,文档介绍这是一个 service 层注解。
要使用这些注解,需要在配置中开启支持。
@enablemethodsecurity(prepostenabled = true, jsr250enabled = true, securedenabled = true) @configuration public class securityconfig { // 省略其他配置 }
三类注解中,spring security 内置注解 prepost 的功能最强大,提供了基于表达式的权限定义和数据过滤的功能,推荐使用。
- preauthorize:指定方法需要的权限
- postauthorize:权限 + 数据过滤,不满足条件时抛出 accessdeniedexception 异常
- prefilter:授权 + 列表过滤,不满足条件的数据会被过滤,不会抛出异常
- postfilter:授权 + 列表过滤,不满足条件的数据会被过滤,不会抛出异常
使用 prefilter 和 postfilter 时,需要考虑数据过滤的性能问题。如果数据量很大,过滤会非常耗时,不如直接在 sql 中限制过滤条件。
资源权限控制的原理
通过 securityfilterchain
配置的 api 权限,在 authorizationfilter 中检查。内部存在 authorizationfilter -> requestmatcherdelegatingauthorizationmanager
的调用链。requestmatcherdelegatingauthorizationmanager 类如其名,会根据 api 的路径来选择实际的 authorizationmanager。
如果在 securityfilterchain 中没有指定 api 权限,只是开启了 authenticated 认证检查,则根据路径匹配到 authenticatedauthorizationmanager,调用链为 authenticatedauthorizationmanager -> authenticationtrustresolverimpl
。authenticationtrustresolverimpl 只会检查 authentication 是否存在用户 userdetails,用户状态是否存在,不涉及权限检查的逻辑。
如果在 securityfilterchain 中用 hasrole
hasauthority
为 api 指定了权限,路径匹配到 authoritiesauthorizationmanager。这又构成了一条新的调用链 authorityauthorizationmanager -> authoritiesauthorizationmanager
。最终,在 authoritiesauthorizationmanager 中,会调用 isauthorized
方法,遍历所有注册的 grantedauthority,检查用户是否拥有权限。
// authoritiesauthorizationmanager private boolean isauthorized(authentication authentication, collection<string> authorities) { for (grantedauthority grantedauthority : getgrantedauthorities(authentication)) { if (authorities.contains(grantedauthority.getauthority())) { return true; } } return false; }
对于方法注解配置的权限,无法直接在 authorizationfilter 中处理。在 filter-servlet 洋葱圈中,filter 只能从 request 中获取信息,无法获取具体处理请求的方法信息。只有到了 servlet 中,才有可能接触到方法信息。
spring mvc 使用 dispatcherservlet 作为 controller 和外部 servlet 容器的桥梁,请求穿过重重 filter 后,到达 dispatcherservlet 的刹那,才真正进入 spring mvc 的世界。dispatcherservlet 内部,也有一个类似的洋葱圈,外层是重重叠叠的 interceptor 拦截器,最内层才是 controller 方法。
在 dispatcher 内部,能获取到 controller 方法信息。因此,注解式的方法权限控制,都是用拦截器 interceptor 实现。
对于注解 @preauthorize("hasauthority('user:edit')")
,调用链大致如下:
authorizationmanagerbeforemethodinterceptor -> preauthorizeauthorizationmanager -> expressionutils
authorizationmanagerbeforemethodinterceptor 是前置拦截器,在 controller 方法执行前,调用 authorizationmanager 检查权限。如果是 @postauthorize 注解,则会使用 authorizationmanageraftermethodinterceptor 后置拦截器。
preauthorizeauthorizationmanager 是具体的权限检查逻辑,与注解 @preauthorize 一一对应。调用 expressionutils 的 evaluate 方法,解析 spel 表达式 hasauthority('user:edit')
,检查用户是否拥有权限。如果是 @postauthorize 注解,则会使用 postauthorizeauthorizationmanager。
解析表达式时,会使用 methodsecurityevaluationcontext
提供的 root 对象。最终执行 hasauthority() 方法的,是 methodsecurityexpressionroot
类。
// methodsecurityexpressionroot private boolean hasanyauthorityname(string prefix, string... roles) { set<string> roleset = getauthorityset(); for (string role : roles) { string defaultedrole = getrolewithdefaultprefix(prefix, role); if (roleset.contains(defaultedrole)) { return true; } } return false; }
getauthorityset() 方法会从 securitycontext 中获取当前用户的权限,roles 参数则代表注解中指定的权限。
可以看到,不管实现方式如何,最终的权限检查逻辑,仍然是对比用户权限和注解权限。掌握这一点,在对 spirng security 进行扩展时就可以灵活变通。
自定义注解控制方法权限
基于注解的权限控制,除了 spring security 提供的注解,还可以使用自定义注解。自定义注解的优点在于实现权限控制的同时,还可以实现自动注册权限的功能。
@requirepermission(code = "user:get", name = "获取用户信息") @getmapping("/{id}") public user getuserbyid(@pathvariable long id) { return userrepository.selectbyprimarykey(id); }
- 应用启动时,会自动注册 user:get 权限。
- 调用 getuserbyid 方法时,也会像 @preauthorize 一样,检查用户是否拥有 user:get 权限。
要实现上述功能,首先需要一个自定义注解,比如 @requirepermission。然后,基于这个注解实现如下功能:
- 应用启动时,扫描注解,注册权限。
- 调用方法时,检查权限。
基于注解自动注册权限
在应用启动时,扫描所有被 @requirepermission 注解的方法,注册权限。将方法权限限制在 controller 层是一个比较合适的粒度。
@target({elementtype.method, elementtype.type}) @retention(retentionpolicy.runtime) public @interface requirepermission { string code(); string name() default ""; string description() default ""; } @component @requiredargsconstructor public class permissionregistrar implements applicationlistener<contextrefreshedevent> { private final permissionservice permissionservice; @override public void onapplicationevent(contextrefreshedevent event) { map<string, object> beans = event.getapplicationcontext().getbeanswithannotation(controller.class); for (object bean : beans.values()) { registerpermissionsforbean(bean); } } private void registerpermissionsforbean(object bean) { // 代理对象无法获取 method 注解,需要用原对象 class<?> clazz = aoputils.gettargetclass(bean); for (method method : clazz.getdeclaredmethods()) { requirepermission annotation = method.getannotation(requirepermission.class); if (annotation != null) { registerpermission(annotation); } } requirepermission requirepermission = clazz.getdeclaredannotation(requirepermission.class); if (requirepermission != null) { registerpermission(requirepermission); } } private void registerpermission(requirepermission requirepermission) { string code = requirepermission.code(); string name = stringutils.defaultifblank(requirepermission.name(), code); string description = stringutils.defaultifblank(requirepermission.description(), name); permissionservice.createpermissionifnotexists(code, name, description); } }
aoputils.gettargetclass 用于获取代理对象的原对象。因为无法直接从代理对象获取方法注解。此外,可以用 commandlinerunner 结合 applicationcontext 来替换 applicationlistener。性能方面,可以先扫描,然后一次性注册,合并数据库写操作。
基于自定义注解检查权限
要为 @requirepermission 实现类似 @preauthorize 的功能,最简单的办法是基于 aop 实现,用切面来处理。优点是不会与框架耦合,只要有 spring boot 就行。缺点是侵入性太强,无法直接使用 spring security 提供的功能。
这里介绍两种用元注解为自定义注解添加权限检查功能的方法。
所谓元注解(meta-annotation),就是修饰注解的注解。比如想实现一个限制 admin 角色的注解,可以这么写:
@target({ elementtype.method, elementtype.type }) @retention(retentionpolicy.runtime) @preauthorize("hasrole('admin')") public @interface isadmin {}
@preauthorize 是元注解,@isadmin 就是被修饰的注解,在 @preauthorize 修饰下,@isadmin 注解就具有了 @preauthorize 的功能。在方法上使用 @isadmin 注解,就可以实现权限检查。
@getmapping("/admin") @isadmin public string admin() { return "admin"; }
@isadmin 的功能比较简单,权限固定,如果要想实现 @isuser 的功能,还得另起炉灶,提供一个新的注解。@requirepermission 则不同,权限不固定,由属性值 code 决定。由于 java 语言本身不支持在元注解获取被修饰注解的属性值,@preauthorize 无法直接获取 code 的值。想要 @requirepermission 能检查权限,还得另想办法,解决元注解无法获取被修饰注解属性值的问题。
使用扩展 spel 表达式
一个简单的解决方案是使用 spring security 6.3 扩展的 spel 表达式,通过 '[code]'
获取注解的属性值。
@target({elementtype.method, elementtype.type}) @retention(retentionpolicy.runtime) @preauthorize("hasauthority('[code]')") public @interface requirepermission { string code(); string name() default ""; string description() default ""; }
执行 hasauthority('[code]')
表达式时,能自动获取 requirepermission.code() 的值,填充进 [code]
占位符中。
但要启用这种使用大括号的表达式,需要向 spring 容器注册 preposttemplatedefaults 类型的 bean。
@enablemethodsecurity(prepostenabled = true) @configuration public class securityconfig { @bean static preposttemplatedefaults preposttemplatedefaults() { return new preposttemplatedefaults(); } }
自己扩展 spel 表达式
在 spring security 6.3 之前,不支持大括号 [code]
获取注解属性的写法,无法直接将注解的属性传递给元注解。我们只能自己扩展 spel 表达式,绕点远路,先利用反射获取注解的属性值,再将属性值传给元注解。
@target({elementtype.method, elementtype.type}) @retention(retentionpolicy.runtime) @preauthorize("hasauthority(@permissionexpressionevaluator.getpermission(#root.method))") public @interface requirepermission { string code(); string name() default ""; string description() default ""; }
在 @preauthorize 中,仍然使用了 hasauthority
表达式,@permissionexpressionevaluator.getpermission()
表示调用名为 permissionexpressionevaluator bean 的 getpermission
方法来获取权限,#root.method
表示获取被注解修饰方法的反射对象 method。
permissionexpressionevaluator 是一个自定义的类,根据传入的反射对象 method,利用反射获取方法上的注解信息,从而得到方法需要的权限,再从安全上下文 authentication 中获取分配给当前用户的权限,两相比较,实现权限检查。
@component public class permissionexpressionevaluator { private final map<string, string> permissioncache = new concurrenthashmap<>(64); public string getpermission(method method) { return permissioncache.computeifabsent(keyof(method), k -> getpermissioncode(method)); } private string getpermissioncode(method method) { requirepermission annotation = method.getannotation(requirepermission.class); if (annotation != null) { return annotation.code(); } // 方法注解优先于类注解 annotation = method.getdeclaringclass().getannotation(requirepermission.class); if (annotation != null) { return annotation.code(); } return ""; } private string keyof(method method) { class<?> clazz = method.getdeclaringclass(); string methodname = clazz.getname() + "." + method.getname(); stringjoiner sj = new stringjoiner(",", methodname + "(", ")"); for (class<?> parametertype : method.getparametertypes()) { sj.add(parametertype.getsimplename()); } return sj.tostring(); } public void clear() { this.permissioncache.clear(); } }
permissionexpressionevaluator 实现 getpermission 方法时,按照先方法注解再类注解的顺序获取权限,保证方法上的注解优先于类上的注解。这一点与 @preauthorize 的行为一致。同时在类和方法使用 @preauthorize 注解时,方法注解会覆盖类注解。此外,为了提高性能,permissionexpressionevaluator 还使用了 map 来缓存方法的权限,避免每次都要靠反射获取。
上述实现需要使用 #root.method
获取方法反射。遗憾的是,spring security 的 spel 表达式不支持这种用法。spring 体系大量使用 spel 表达式,但不同的模块会提供不同的 context。context 不同,表达式可以获取的信息也不同。比如 spring security 的 context 中,可以使用 hasrole
、hasauthority
等方法,而 web 模块的 context 中,可以使用 #request
获取 httpservletrequest 对象。spring security 解析注解的 spel 使用的 context 为 methodsecurityevaluationcontext
,内部有一个 methodsecurityexpressionroot
类型的属性。root 决定了 spel 表达式可以获取的信息。用表达式可以直接调用 root 的方法,比如 hasrole
、hasauthority
;用 #root.property
表达式可以访问 property 属性,比如 #root.method
,就需要 root 提供了名为 method
的属性。
现在的 methodsecurityexpressionroot 并没有 method 属性,但可以通过扩展 root 来实现。我们可以继承 methodsecurityexpressionroot
,添加 method
属性,再将新的 root 传递给 context。
具体代码如下:
public class custommethodsecurityexpressionroot extends securityexpressionroot implements methodsecurityexpressionoperations { private object filterobject; private object returnobject; private object target; /** * 仅仅添加了 method 属性,其他都与 methodsecurityexpressionroot 保持一致 */ @getter @setter private method method; public custommethodsecurityexpressionroot(authentication a) { super(a); } public custommethodsecurityexpressionroot(supplier<authentication> authentication) { super(authentication); } @override public void setfilterobject(object filterobject) { this.filterobject = filterobject; } @override public object getfilterobject() { return this.filterobject; } @override public void setreturnobject(object returnobject) { this.returnobject = returnobject; } @override public object getreturnobject() { return this.returnobject; } void setthis(object target) { this.target = target; } @override public object getthis() { return this.target; } } /** * 重写 methodsecurityexpressionhandler,用于设置自定义 root 对象 */ public class custommethodsecurityexpressionhandler extends defaultmethodsecurityexpressionhandler { /** * 重写以避免调用父类的 createsecurityexpressionroot 方法 */ @override public evaluationcontext createevaluationcontext(supplier<authentication> authentication, methodinvocation mi) { /* createsecurityexpressionroot 是 private 方法,由 invokespecial 指令调用,采用解析方式来确定方法版本。 解析会直接根据外观类型来确定方法,因此如果不重写 createevaluationcontext 方法, 就会直接调用 defaultmethodsecurityexpressionhandler 的 createsecurityexpressionroot 方法。 */ methodsecurityexpressionoperations root = createsecurityexpressionroot(authentication, mi); // 为了解决 methodsecurityevaluationcontext 不可见问题 custommethodsecurityevaluationcontext ctx = new custommethodsecurityevaluationcontext(root, mi, getparameternamediscoverer()); ctx.setbeanresolver(getbeanresolver()); return ctx; } @override protected methodsecurityexpressionoperations createsecurityexpressionroot( authentication authentication, methodinvocation invocation) { return createsecurityexpressionroot(() -> authentication, invocation); } private methodsecurityexpressionoperations createsecurityexpressionroot(supplier<authentication> authentication, methodinvocation invocation) { custommethodsecurityexpressionroot root = new custommethodsecurityexpressionroot(authentication); root.setthis(invocation.getthis()); root.setpermissionevaluator(getpermissionevaluator()); root.settrustresolver(gettrustresolver()); root.setrolehierarchy(getrolehierarchy()); root.setdefaultroleprefix(getdefaultroleprefix()); root.setmethod(invocation.getmethod()); return root; } } /** * methodsecurityevaluationcontext 是 default 可见,为了在 custommethodsecurityexpressionhandler 中使用,复制过来 */ public class custommethodsecurityevaluationcontext extends methodbasedevaluationcontext { public custommethodsecurityevaluationcontext(methodsecurityexpressionoperations root, methodinvocation mi, parameternamediscoverer parameternamediscoverer) { super(root, getspecificmethod(mi), mi.getarguments(), parameternamediscoverer); } private static method getspecificmethod(methodinvocation mi) { return aoputils.getmostspecificmethod(mi.getmethod(), aopproxyutils.ultimatetargetclass(mi.getthis())); } }
重写的类型比较多,主要逻辑如下:
- 由于 methodsecurityexpressionroot 是 private 可见,无法直接继承,所以 custommethodsecurityexpressionroot 复制了 methodsecurityexpressionroot 的代码,添加了 method 属性。
- 为了使用自定义的 root,还需要重写 defaultmethodsecurityexpressionhandler 的 createsecurityexpressionroot 方法,返回自定义的 root。
- 仅仅重写 createsecurityexpressionroot 方法还不够,由于框架直接调用 defaultmethodsecurityexpressionhandler 的 createevaluationcontext 方法来获取 context,而这个方法内部调用了 private 版的 createsecurityexpressionroot,为了避免解析到父类,还需要重写 createevaluationcontext 方法。
- 重写 createevaluationcontext 方法时,由于默认的 methodsecurityevaluationcontext 对外不可见,所以又复制了一个一模一样的 custommethodsecurityevaluationcontext 类。
总结下来,关键是为 root 添加 method 属性,以及使 methodsecurityexpressionhandler 使用自定义的 root,其他都是为了解决可见性问题而复制了一堆代码。
现在,我们还需要将一切组合起来,就可以使用 @requirepermission 注解来实现权限控制了。
@enablewebsecurity @enablemethodsecurity(prepostenabled = true) @configuration class securityconfig { @bean methodsecurityexpressionhandler expressionhandler() { // 用自定义的 handler 替换默认的 defaultmethodsecurityexpressionhandler return new custommethodsecurityexpressionhandler(); } }
此时,@preauthorize 等 method security 注解的表达式中,就可以使用 #root.method
就可以获取到方法反射对象。@requirepermission 注解也就能正常工作了。
如果想要扩展其他功能,也可以采用类似的思路:
- 自定义一个 root,扩充属性或者添加方法。
- 自定义 handler,使用自定义的 root。
- 解决各种可见性问题。
总结
本文介绍了在 spring boot 中用 spring security 实现 rbac 权限管理的方案,并提供了自定义注解来简化权限管理的思路。想要实现自定义注解,需要解决元注解无法获取被修饰注解属性值的问题。spring security 6.3 之后可以直接使用大括号表达式获取注解属性,而之前的版本需要自己扩展 spel 表达式来实现。
参考文章
[1] method security :: spring security 文档
到此这篇关于springsecurity实现rbac权限管理的文章就介绍到这了,更多相关springsecurity rbac权限内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论