本文涉及的源码地址:https://github.com/davidfantasy/shrio-with-jwt-spring-boot-starter
背景说明
用户权限管理是每个信息系统最基本的需求,对基于Java的项目来说,最常用的权限管理框架就是大名鼎鼎的Apache Shiro。Apache Shiro功能非常强大,使用广泛,几乎成为了权限管理的代名词。但对于普通项目来说,Shiro的设计理念因为追求灵活性,一些概念如Realm,Subject的抽象级别都比较高,显得比较复杂。如果没有对框架细节进行深入了解的话,很难理解其中的准确含义。要将其应用于实际项目,还需要针对项目的实际情况做大量的配置和改造,时间成本较高。
而且Shiro兴起的时代主流应用还是传统的基于Session的Web网站,并没有过多的考虑目前流行的微服务等应用形式的权限管理需求。导致其并没有提供一套无状态微服务的开箱即用的整合方案。需要在项目层面对Shiro进行二次封装和改进,开发难度较大。
我负责的几个项目都使用了Shiro作为权限管理框架,感叹其强大功能的也为每次都需要进行二次开发和封装感到厌烦了,于是在对Shiro的结构有比较深入的了解之后,决定在Shrio的基础上,对一些常用的开发场景进行封装和整合,提高开发效率,降低配置难度,开发一套基于Spring Boot环境,适合于各类无状态微服务应用的,开箱即用的轻量级权限框架。
使用Aceess Token替换Session
所谓的无状态,其实是把原来由后端服务负责维护的,基于Http Session的用户会话信息交由客户端(如果是普通的web应用,客户端即是用户的浏览器)进行维护,这样后端服务的单元测试,负载均衡,横向扩容都要方便很多。
但是用户会话信息关乎数据安全,放到客户端如何确保安全呢?常见的做法是由服务端根据客户端首次提交的认证信息签发一个accessToken,这个accessToken就相当于客户端的身份证,以后每次交互的时候客户端只需要出示这个凭证,服务端就能够识别当前客户端的身份。
实现accessToken的方式有很多,理论上只要确保一个accessToken无法被第三方解码,能唯一标识一个客户端,服务端能够解析出token的创建时间,客户端标识等内容就行了。但是自行设计的实现方法难免存在各种安全隐患,accessToken是要由客户端进行维护的,我们无法确保客户端都一定运行在完全安全的环境中。幸运的是现在有一种专门为此目的而设计的开放标准JWT(JSON Web token),它基于http交互中常见的数据格式JSON,提供紧凑而安全的Token生成处理机制。JWT的详细内容这里就不多介绍了,有兴趣可以自行查阅相关资料。
认证和授权
Shiro默认提供的实现是基于用户Session的权限验证模型,如何让其支持基于accessToken的无状态形式呢?Shiro功能的核心其实主要包含两部分内容:认证(Authentication)和授权(Authorization)。认证就是核实用户身份的过程,比如检查客户端提供的用户名和密码是否是合法的系统用户。而授权的含义则是检查该用户是否能够访问具体的某个资源,也就是访问控制。所以我们需要做的就是扩展Shiro对于认证和授权的默认实现,使其能够支持accessToken的形式。
我们先来看一下Shiro实现认证的流程:
从整个流程图上可以看出,最终实现认证逻辑的组件是所谓的Realm,Shiro默认实现了很多不同的Realm,可以从数据库,LADP等各个地方加载用户的认证信息。
授权过程和认证过程差不多,核心也在于Realm的实现:
所以,第一步要做的应该是先实现一个自定义的JWTShiroRealm,采用accessToken的方式来实现系统的认证和授权。
public class JWTShiroRealm extends AuthorizingRealm {
/**
* 可供扩展的权限加载器,由应用程序负责实现
*/
private JWTUserAuthService userAuthService;
private JWTHelper jwtHelper;
public JWTShiroRealm(JWTUserAuthService userAuthService, JWTHelper jwtHelper) {
this.jwtHelper = jwtHelper;
this.userAuthService = userAuthService;
}
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JWTToken;
}
/**
* 用于获取用户权限(role,permissions),只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
JWTPrincipal principal = (JWTPrincipal) principals.getPrimaryPrincipal();
SimpleAuthorizationInfo authInfo = new SimpleAuthorizationInfo();
UserInfo up = userAuthService.getUserInfo(principal.getAccount());
if (up != null && up.getPermissions() != null) {
authInfo.addStringPermissions(up.getPermissions());
}
return authInfo;
}
/**
* 调用subject.login时触发此方法,用于验证token的正确性
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
String token = (String) auth.getCredentials();
// 从token中获取用户的唯一标识
String account = jwtHelper.getAccount(token);
if (account== null) {
throw new AuthenticationException("无效的请求");
}
UserInfo user = userAuthService.getUserInfo(account);
if (user == null) {
throw new AuthenticationException("未找到用户信息");
}
DecodedJWT jwt = jwtHelper.verify(token, account, user.getSecret());
if (jwt == null) {
throw new AuthenticationException("token已经过期,请重新登录");
}
JWTPrincipal principal = new JWTPrincipal();
principal.setAccount(user.getAccount());
principal.setExpiresAt(jwt.getExpiresAt().getTime());
//这里实际上会将AuthenticationToken.getCredentials()与传入的第二个参数credentials进行比较
//第一个参数是登录成功后,可以通过subject.getPrincipal获取
return new SimpleAuthenticationInfo(principal, token, this.getName());
}
Realm核心的方法在于doGetAuthorizationInfo和doGetAuthenticationInfo,对应之前说的授权和认证过程。这里需要注意的地方是,解析了accessToken之后应该如何获得用户的认证和权限等信息呢?我的想法是,认证方式和获取用户权限每个应用系统都可能有不同的需求,没办法强行统一起来,所以这里应该预留一个扩展点。JWTShiroRealm只负责对accessToken的有效性进行认证,而把该Token是否对应一个合法的用户以及用户的具体权限委派给具体的应用去处理和实现。 而JWTUserAuthService就是这样一个扩展点,它被定义成一个接口,负责根据accessToken中定义的唯一标示(一般就是用户账号)判断用户是否合法,以及通过用户的唯一标示加载该用户的实际权限,另外还可以自定义验证失败时的错误返回。每个应用程序需要根据业务特点实现自己的逻辑。
public interface JWTUserAuthService {
/**
* 根据用户的唯一标示对用户进行认证,并获取用户的权限等信息
* 如果account对应的用户信息不存在,应返回null
* @param account 用户的唯一标示
* @return 该用户所拥有的权限信息
*/
UserInfo getUserInfo(String account);
/**
* 自定义访问资源认证失败时的处理方式,例如返回json格式的错误信息
* {\"code\":401,\"message\":\"用户认证失败!\")
*/
void onAuthenticationFailed(HttpServletRequest req, HttpServletResponse res);
/**
* 自定义访问资源权限不足时的处理方式,例如返回json格式的错误信息
* {\"code\":403,\"message\":\"permission denied!\")
*/
void onAuthorizationFailed(HttpServletRequest req, HttpServletResponse res);
}
其中UserInfo类封装了认证用户所拥有的权限信息
public class UserInfo {
/**
* 用户的唯一标识
*/
private String account;
/**
* accessToken的密钥,用于对accessToken进行加密和解密
* 建议为每个用户配置不同的密钥(比如使用用户的password)
*/
private String secret;
/**
* 用户权限集合,含义类似于Shiro中的perms
*/
private Set<String> permissions;
}
通过将认证和授权逻辑与accessToken的处理进行分离,应用程序就可以仅仅关注于具体的权限管理模型的实现,而无需操心accessToken的相关问题了。这里有一个地方与常见的权限模型有一些差异。通常的系统一般采用基于角色的访问控制模型(RBAC),主要由三个主体构成:用户(User) — 角色(Role)— 权限(Permission)。但我在这里省略掉了Role这样一个主体,用户的授权信息中直接包含了该用户的权限(Permission),并没有Role的相关信息。这样设计最大的好处就是简单,一个链接所对应的权限仅仅只有Permission。而不像Shiro原本那样,一个链接的访问权限既可以使用Role又可以使用Permission来控制,如果使用不当反而会出现安全漏洞。但如果系统中要求使用角色来控制权限怎么办呢?其实在UserInfo中省略掉Role并不意味着不能有Role的存在,应用程序在实现权限模型的时候可以完全按照自身的需求,只是在最终返回UserInfo的时候需要将Role转换成Permission。比如通过account去查询用户的角色,再将返回该用户所有角色所具备的权限就行了。这样的实现其实比Role更加的灵活,比如某些系统的用户的权限不是由角色决定的,而是用户所在的部门决定的,那只需要在实现getUserInfo方法的时候,返回用户所在部门的权限就好了。
权限过滤器
说完最核心的认证和授权过程,我们再来看一看Shiro框架的Filter机制。Realm中的认证和授权过程最终就是在各个Filter中触发的。这里的Filter并不是Java Servlet规范中定义的Filter,而是Shrio内置的用于控制资源访问的不同规则。Shiro内置了很多Filter的实现,但最常用的有4种:
- anon: 匿名访问过滤器,添加了此过滤器的资源无需任何验证即可访问。例如:/login/**=anon
- authc:认证过滤器,通过调用subject.isAuthenticated来判断当前用户是否被认证过,资源需要通过认证(登录)才能使用。例如:/api/**=authc
- roles: 角色过滤器,通过调用subject.hasRole来判断当前用户是否拥有指定的角色。例如:/admins/**=roles["admin"]
- perms: 权限过滤器, 通过调用subject.isPermitted来判断当前用户是否拥有指定的权限,例如:/api/data/add = perms['data:modify']
roles过滤器因为前述的原因我们这里不会涉及。anon,perms过滤器基本可以沿用Shiro的默认实现,但authc过滤器默认是从用户会员会话中去获取用户的认证状态的,所以我们需要对其进行一定的改造。以下是传统认证方式和无状态认证方式的流程对比:
重新实现authc过滤器的逻辑,需要继承org.apache.shiro.web.filter.AccessControlFilter,这个是Shiro用于资源访问控制最基础的filter。核心是要重写isAccessAllowed和onAccessDenied方法,大致代码如下:
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
//从header或URL参数中查找token
HttpServletRequest req = (HttpServletRequest) request;
String authorization = req.getHeader(headerKeyOfToken);
if (Strings.isNullOrEmpty(authorization)) {
authorization = req.getParameter(headerKeyOfToken);
}
JWTToken token = new JWTToken(authorization);
try {
getSubject(request, response).login(token);
} catch (Exception e) {
logger.error("认证失败:" + e.getMessage());
return false;
}
return true;
}
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException {
this.userAuthService.onAuthenticationFailed((HttpServletRequest) request, (HttpServletResponse) response);
return false;
}
资源和权限的映射
Shiro 最常用的两种将资源和权限进行关联的方式:
- 通过外部配置,将url和权限绑定。例如:
<property name="filterChainDefinitions">
<value>
/static/** = anon
/api/** = authc
/api/user = perms["user"]
</value>
</property>
- 基于annotation将类或方法的执行与用户权限进行绑定,例如:
//执行这个方法,需要当前用户具有user:modify权限
@RequiresPermissions("user:modify")
public void modifyUserInfo(){
}
这两种方式都有一定的缺点。第一种将配置放到独立的配置文件中,与代码分离了。而系统暴露的服务地址随时都有可能发生变更,一旦代码与配置没有及时同步更新,就会出现安全隐患;第二种基于Annotation的配置能很好的解决这个问题,但是Shiro原生的注解是基于AOP的, 必须要求被保护的类启用动态代理。而且每个需要被保护的类或者方法都需要添加对应的注解,无法像配置url那样使用模式匹配。
我在实现上将两种方式综合了一下,使用基于URL的注解方式来尽可能避免上述的缺陷。我定义了两个新的注解AlowAnonymous和RequiresPerms,和Shiro原生注解的区别是这两个注解必须要与Spirng的RequestMapping(包括GetMapping,PostMapping等)注解结合进行使用。无需动态代理,框架会通过获取RequestMapping定义的url,将其自动与RequiresPerms标注的权限字段进行绑定,这也意味着这两个注解只允许在Controller中进行使用。
@RestController
@RequestMapping("/api/user")
@RequiresPerms("user:basic")
public class UserController {
@AlowAnonymous
@PostMapping("/login")
public String login() {
return "ok";
}
@GetMapping("/detail")
public String getUserDetail() {
return "ok";
}
@PostMapping("/modify")
@RequiresPerms("user:modify")
public String modifyUser() {
return "ok";
}
@PostMapping("/delete")
@RequiresPerms({"system","user:delete"})
public String deleteUser() {
return "ok";
}
@PostMapping("/modify-logs")
@RequiresPerms(value={"system","user:logs"}, logical = Logical.OR)
public String deleteUser() {
return "ok";
}
}
例如上面的代码等同于如下的Shiro配置:
/api/user/login = anon
/api/user/detail = perms[ user:basic ]
/api/user/modify = perms[ user:modify ]
/api/user/delete = perms[ system,user:delete ]
#默认的shiro配置并不支持配置OR的比较操作符,这里的anyPerms是自定义过滤器
/api/user/modify-logs= anyPerms[ system,user:logs ]
为了进一步减少一些无谓配置,框架默认所有被拦截的资源必须是要经过认证的用户才可以被访问。即如果配置的拦截范围是/api/,则会添加一条默认的验证规则: /api/=authc。但任何通过注解添加的验证规则都拥有比默认规则更高的优先级。
accessToken的自动刷新
accessToken是客户端用于访问授权资源的重要凭证,accessToken本身是由客户端进行维护的,存在泄漏或者被截取的危险。为了最大程度的保证安全,accessToken本身必须包含一个合理的有效期限。过期之后,必须重新进行客户端的认证过程,获取新的token。但这里存在一个问题,客户端可能无法获取到Token的实际超时时间(或者由于时钟同步的原因不能精确的判定),如果等到服务端返回token失效的信息后再重新请求认证,必然会导致当前处理流程的中断,如果是面向用户的web系统,则意味着用户的操作被强制中断需要重新进行登录。这样的用户体验显然是不好的。考虑到传统的基于Session的web应用,用户的每次后台操作都会刷新Session的过期时间,只要用户持续的操作Session就不会过期,我在本框架中也引入了类似的Token刷新机制,大概流程图如下:
如果开启token的自动刷新,框架会自动注册一个Spring HandlerInterceptor来拦截所有被保护的接口。在检测到token即将过期,但还没有超过最大生命周期时,就会自动刷新token并在响应的header中加入该token。这样客户端可以通过每次检查请求的响应头,如果发现携带了新的token,就自动更新自身存储的token。这样只要在token的生命周期内不断有新的请求,则token就会不断的刷新。