2021年发布shiro1.8带来了质的飞跃,对于本文的需求来说,最利好的包括两点:一是增加了对SpringBoot自动装配机制的支持;二是增加了BearerHttpAuthenticationFilter这个默认过滤器,从而让Jwt的整合获得了原生级的适配性。以上两项特性大大精简了我们的配置工作,且让当前网络上所有的教程都落后于时代。(包括官网和英文网络,搜到的教程基本都是旧版本的配置。)
你也可以直接使用框架KRest来实现两者的集成使用,该框架在整合了常用的shiro+jwt+通信加密模块的基础上,提供了一套极为简便易用的一体化配置。
项目发布在gitee上 https://gitee.com/ckw1988/krest ,源码同时也发布到了maven中央库,直接配置依赖即可使用。如果你因为工作原因需要快速搭建一套此类系统,又无暇深入学习本帖的原理机制,则非常适合选用该产品。
如果您出于对知识的热忱和追求依然打算自己亲手完成一套shiro+jwt的配置,那么请继续往下看下去。本文在介绍配置时会深入讲解一些相关shiro和jwt的机制原理,所以此贴同时也是一篇极好的机制原理介绍教程。
话不多说,开搞。
示例源码本教程的源码地址为 https://gitee.com/ckw1988/shiro-jwt-integration
并包含一个调试用的postman脚本,强烈建议下载下来跑通了再来看教程,心里比较踏实。
配置文件首先在pom里配上shiro1.8
org.apache.shiro shiro-spring-boot-web-starter 1.8.0
然后是配置文件,如今在的1.8也换上了springboot自动装配机制,config中只需配置两个bean。代码如下:
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
Map filterRuleMap = new HashMap<>();
filterRuleMap.put("/static
@PostMapping("/login")
public Map login(@RequestBody User userInput) throws Exception {
String username = userInput.getUsername();
String password = userInput.getPassword();
Assert.notNull(username, "username不能为空");
Assert.notNull(password, "password不能为空");
UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username, password);
Subject subject = SecurityUtils.getSubject();
subject.login(usernamePasswordToken);//显示调用登录方法
//生成返回token
Map res=new HashMap<>();
JwtUser jwtUser = (JwtUser) SecurityUtils.getSubject().getPrincipal();
res.put("token",JwtUtil.createJwtTokenByUser(jwtUser));
res.put("result","login success or other result message");
return res;
}
subject.login(usernamePasswordToken)的操作,事实上是就进入了由realm处理身份验证的环节。我们先看代码
//Username Password Realm,用户名密码登陆专用Realm
@Slf4j
@Component
public class UsernamePasswordRealm extends AuthenticatingRealm {
@Autowired
private UserService userService;
public UsernamePasswordRealm() {
super();
HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
hashedCredentialsMatcher.setHashAlgorithmName("md5");
hashedCredentialsMatcher.setHashIterations(2);//密码保存策略一致,2次md5加密
this.setCredentialsMatcher(hashedCredentialsMatcher);
}
@Override
public Class getAuthenticationTokenClass() {
log.info("getAuthenticationTokenClass");
return UsernamePasswordToken.class;
}
@Override
public boolean supports(AuthenticationToken token) {
//继承但啥都不做就为了打印一下info
boolean res = super.supports(token);//会调用↑getAuthenticationTokenClass来判断
log.debug("[UsernamePasswordRealm is supports]" + res);
return res;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) {
UsernamePasswordToken usernamePasswordToken=(UsernamePasswordToken) token;
User userFromDB=userService.queryUserByName(usernamePasswordToken.getUsername());
String passwordFromDB = userFromDB.getPassword();
String salt = userFromDB.getSalt();
//在使用jwt访问时,shiro中能拿到的用户信息只能是token中携带的jwtUser,所以此处保持统一。
JwtUser jwtUser=new JwtUser(userFromDB.getUsername(),userFromDB.getRoles());
SimpleAuthenticationInfo res = new SimpleAuthenticationInfo(jwtUser, passwordFromDB, ByteSource.Util.bytes(salt),
getName());
return res;
}
}
首先是覆盖getAuthenticationTokenClass方法,此时设定返回值为UsernamePasswordToken.class。shiro的机制是根据login方法中传入的token类型来分配realm,步骤1中是UsernamePasswordToken,所以分配给本realm来处理。
@Override
public Class getAuthenticationTokenClass() {
log.info("getAuthenticationTokenClass");
return UsernamePasswordToken.class;
}
doGetAuthenticationInfo中的返回值按照new SimpleAuthenticationInfo(jwtUser, passwordFromDB, ByteSource.Util.bytes(salt),getName());来配,第一个参数依然是登陆成功后的用户信息,第三个是密码的盐。
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) {
UsernamePasswordToken usernamePasswordToken=(UsernamePasswordToken) token;
User userFromDB=userService.queryUserByName(usernamePasswordToken.getUsername());
String passwordFromDB = userFromDB.getPassword();
String salt = userFromDB.getSalt();
//在使用jwt访问时,shiro中能拿到的用户信息只能是token中携带的jwtUser,所以此处保持统一。
JwtUser jwtUser=new JwtUser(userFromDB.getUsername(),userFromDB.getRoles());
SimpleAuthenticationInfo res = new SimpleAuthenticationInfo(jwtUser, passwordFromDB, ByteSource.Util.bytes(salt),
getName());
return res;
}
}
密码验证策略是md5哈希2次加盐,因为这个验证规则shiro里有现成的实现,就不用自己写了,直接用HashedCredentialsMatcher即可。这部分其实更推荐自定义matcher,用自己熟悉的加密策略和加密工具自由地实现,学习成本更低,灵活度更高。这里还是演示用他自带的用法,自定义的示例参考后面jwt的realm。
public UsernamePasswordRealm() {
super();
HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
hashedCredentialsMatcher.setHashAlgorithmName("md5");
hashedCredentialsMatcher.setHashIterations(2);//密码保存策略一致,2次md5加密
this.setCredentialsMatcher(hashedCredentialsMatcher);
}
再次提醒一下不要遗漏@Component注解。
进阶扩展事实上这个UsernamePasswordRealm是个可选环节。获得初始jwt token的方式多种多样,可以是用户名密码登陆,可以是手机+验证码登陆,可以是第三方平台登录,甚至可以是通过其他服务登录已经获得了jwt token后再拿到本服务上来使用。
所以事实上最简单做法是,只要你认为某个登陆请求已经完成了登陆步骤,只需要在返回值中带上一个新token
……
res.put("token",JwtUtil.createJwtTokenByUser(jwtUser));
即可视为登陆成功。之后的其他请求自然会进入你在TokenValidateAndAuthorizingRealm中定义好的验证流程来处理。
TokenValidateAndAuthorizingRealm @Slf4j
@Component
public class TokenValidateAndAuthorizingRealm extends AuthorizingRealm {
//权限管理部分的代码先行略过
//......
public TokenValidateAndAuthorizingRealm() {
//CredentialsMatcher,自定义匹配策略(即验证jwt token的策略)
super(new CredentialsMatcher() {
@Override
public boolean doCredentialsMatch(AuthenticationToken authenticationToken, AuthenticationInfo authenticationInfo) {
log.info("doCredentialsMatch token合法性验证");
BearerToken bearerToken = (BearerToken) authenticationToken;
String bearerTokenString = bearerToken.getToken();
log.debug(bearerTokenString);
boolean verified = JwtUtil.verifyTokenOfUser(bearerTokenString);
return verified;
}
});
}
@Override
public String getName() {
return "TokenValidateAndAuthorizingRealm";
}
@Override
public Class getAuthenticationTokenClass() {
//设置由本realm处理的token类型。BearerToken是在filter里自动装配的。
return BearerToken.class;
}
@Override
public boolean supports(AuthenticationToken token) {
boolean res=super.supports(token);
log.debug("[TokenValidateRealm is supports]" + res);
return res;
}
@Override//装配用户信息,供Matcher调用
public AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException, TokenExpiredException {
log.debug("doGetAuthenticationInfo 将token装载成用户信息");
BearerToken bearerToken = (BearerToken) authenticationToken;
String bearerTokenString = bearerToken.getToken();
JwtUser jwtUser = JwtUtil.recreateUserFromToken(bearerTokenString);//只带着用户名和roles
SimpleAuthenticationInfo res = new SimpleAuthenticationInfo(jwtUser, bearerTokenString, this.getName());
// 这个返回值是造Subject用的,返回值供createSubject使用
return res;
}
该realm的功能除了身份验证还包含权限控制。为免干扰理解先行省略,后面会单独介绍,这里先说身份验证。
首先,让客户端在请求中带上jwt token。按照jwt的通用规范,具体的做法是客户端将token字符串加上"Bearer "前缀后放在头信息的Authorization字段里。该信息会在authcBearer过滤器中自动解析,并将其所携带的jwt token内容包装成一个BearerToken对象。这一部分可参考实例源码中的postman脚本。
然后实现realm的代码,依然覆盖getAuthenticationTokenClass方法,本类中令该方法返回BearerToken.class。该token由authcBearer filter自动封装而成。由此shiro就就会将authcBearer filter中发起的,用于验证jwt的login操作交给该realm处理。
@Override
public Class getAuthenticationTokenClass() {
//设置由本realm处理的token类型。BearerToken是在filter里自动装配的。
return BearerToken.class;
}
注意区分两种token的概念,jwt token是一串字符串,用于在客户端和服务端常规通信时的身份保持。而shiro中的token是一个java bean,它是对用户身份信息的一种封装,用于服务器内部、在shiro框架中包装和传递待验证的用户信息:在用户名密码登陆时它是封装了用户名密码的UsernamePasswordToken,在jwt验证时它是封装了jwt token字符串的BearerToken。
接下来是实现doGetAuthenticationInfo方法,该方法依然不是真正的身份验证过程,而是装配登陆成功后的用户信息(返回值的第一个参数)和供验证的身份信息(返回值的第二个参数),第三个参数大约是用于区分本次登陆是由哪个realm通过的,不太重要,带上即可。
@Override//装配用户信息,供Matcher调用
public AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException, TokenExpiredException {
log.debug("doGetAuthenticationInfo 将token装载成用户信息");
BearerToken bearerToken = (BearerToken) authenticationToken;
String bearerTokenString = bearerToken.getToken();
JwtUser jwtUser = JwtUtil.recreateUserFromToken(bearerTokenString);//只带着用户名和roles
SimpleAuthenticationInfo res = new SimpleAuthenticationInfo(jwtUser, bearerTokenString, this.getName());
// 这个返回值是造Subject用的,返回值供createSubject使用
return res;
}
配置一个CredentialsMatcher。该对象才是真正处理验证登陆的步骤,我将其用匿名类创建在realm的构造器里,语法很好懂,看源码即可。
public TokenValidateAndAuthorizingRealm() {
//CredentialsMatcher,自定义匹配策略(即验证jwt token的策略)
super(new CredentialsMatcher() {
@Override
public boolean doCredentialsMatch(AuthenticationToken authenticationToken, AuthenticationInfo authenticationInfo) {
log.info("doCredentialsMatch token合法性验证");
BearerToken bearerToken = (BearerToken) authenticationToken;
String bearerTokenString = bearerToken.getToken();
log.debug(bearerTokenString);
boolean verified = JwtUtil.verifyTokenOfUser(bearerTokenString);
return verified;
}
});
}
同时,该步骤中还用到了工具类JwtUtil,代码如下:
@Slf4j
public class JwtUtil {
//指定一个token过期时间(毫秒)
private static final long EXPIRE_TIME = 20 * 60 * 1000; //20分钟
private static final String JWT_TOKEN_SECRET_KEY = "yourTokenKey";
//↑ 记得换成你自己的秘钥
public static String createJwtTokenByUser(JwtUser user) {
String secret = JWT_TOKEN_SECRET_KEY;
Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
Algorithm algorithm = Algorithm.HMAC256(secret); //使用密钥进行哈希
// 附带username信息的token
return JWT.create()
.withClaim("username", user.getUsername())
.withClaim("roles", user.getRoles())
// .withClaim("permissions",permissionService.getPermissionsByUser(user))
.withExpiresAt(date) //过期时间
.sign(algorithm); //签名算法
//r-p的映射在服务端运行时做,不放进token中
}
public static boolean verifyTokenOfUser(String token) throws TokenExpiredException {//user要从sercurityManager拿,确保用户用的是自己的token
log.info("verifyTokenOfUser");
String secret = JWT_TOKEN_SECRET_KEY;//
//根据密钥生成JWT效验器
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier verifier = JWT.require(algorithm)
.withClaim("username", getUsername(token))//从不加密的消息体中取出username
.build();
//生成的token会有roles的Claim,这里不加不知道行不行。
// 一个是直接从客户端传来的token,一个是根据盐和用户名等信息生成secret后再生成的token
DecodedJWT jwt = verifier.verify(token);
//能走到这里
return true;
}
public static String getUsername(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("username").asString();
} catch (JWTDecodeException e) {
return null;
}
}
public static JwtUser recreateUserFromToken(String token) {
JwtUser user = new JwtUser();
DecodedJWT jwt = JWT.decode(token);
user.setUsername(jwt.getClaim("username").asString());
user.setRoles(jwt.getClaim("roles").asList(String.class));
//r-p映射在运行时去取
return user;
}
public static boolean isExpire(String token) {
DecodedJWT jwt = JWT.decode(token);
return jwt.getExpiresAt().getTime() < System.currentTimeMillis();
}
}
因为封装比较简单,看看源码和注解即可。
该类中所用到的JWT验证框架是
com.auth0 java-jwt 3.18.2
配到pom里去。
至此,jwt验证部分的功能配置完毕。DemoController中的whoami方法是这部分的使用范例。
@GetMapping("/whoami")
public Map whoami(){
JwtUser jwtUser = (JwtUser) SecurityUtils.getSubject().getPrincipal();
Map res=new HashMap<>();
res.put("result","you are "+jwtUser);
res.put("token",JwtUtil.createJwtTokenByUser(jwtUser));
return res;
}
JwtUser是携带在JwtToken中的用户信息,因为no-session服务不再储存用户信息,所以用户信息就得放在jwtToken中携带,这也是jwt的规范之一。同时这个jwtUser也即是在先前第3步骤的返回值第一个参数中配置进去的用户信息,你可以根据需要自行设定这个对象,步骤3中传进去啥,getSubject中取出来的就是啥。
注意返回值中还需要加上新生成的Jwt token,因为token有过期时间,所以一次成功的带jwt的请求成功返回时,还应当把新的token带给客户端,供它下次请求时使用。进阶些的做法是仅在token即将过期时才生成新token返回给客户端,从而节约一些服务器资源。
客户端在拿到返回信息后,将token中的内容取代步骤1中的旧token,下次请求时用同样的规则带上即可。如果用了即将过期时才刷新token的机制且还没到token刷新时间,则继续使用旧token即可。如此新token连续不断地替换掉旧token,用户的登录状态就能视为一直保持。
当然如果两次请求的间隔时间超过了token中预设的过期时间(即上面JWTUtil源码中的EXPIRE_TIME),则token验证会不通过,提示tokne过期,此时客户端应重新把页面跳转到用户名和密码的登录页要求用户重新登录。
首先你的用户-权限的数据模型要符合RBAC规范,这个概念这里不再赘述。
因为服务端不存用户信息了,所以此时role、permission和这两级数据和user怎么关联就是一个问题,我这里决定的方案是,roles信息跟着user一起存在jwt token里,然后permissions和role的对应因为相对固定,所以在服务端维护一份对应表即可。
代码也是在TokenValidateAndAuthorizingRealm中,这里把权限相关部分贴一遍
@Slf4j
@Component
public class TokenValidateAndAuthorizingRealm extends AuthorizingRealm {
UserService userService;
Map> rolePermissionsMap;
@Autowired
public void setUserService(UserService userService){
this.userService=userService;
rolePermissionsMap= userService.getRolePermissionMap();
//自动注入时查询一次存成变量,避免每次权限管理都去调用userService
}
……//身份验证部分省略
@Override//权限管理
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
log.debug("doGetAuthorizationInfo 权限验证");
JwtUser user = (JwtUser) SecurityUtils.getSubject().getPrincipal();
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
simpleAuthorizationInfo.addRoles(user.getRoles());//roles跟着user走,放到token里。
Set stringPermissions = new HashSet();
for (String role : user.getRoles()) {
stringPermissions.addAll(rolePermissionsMap.get(role));
}
simpleAuthorizationInfo.addStringPermissions(stringPermissions);
return simpleAuthorizationInfo;
}
}
rolePermissionsMap顾名思义,就是所有角色-权限的对照表。这里配置一份后供以后每次用户有需要时调用,查出权限集合。
doGetAuthorizationInfo方法,本质是返回当前用户所拥有的角色和权限的集合,角色本身就存在token里,用user.getRoles()即可获取;权限通过对照表(rolePermissionsMap),由roles查询添加而来,代码应该都不难懂。
在controller中配一个这样的方法来试用该功能
@GetMapping("/permissionDemo")
@RequiresPermissions("pd")
public Map permissionDemo(){
Map res=new HashMap<>();
res.put("result","you have got the permission [pd]");
JwtUser jwtUser = (JwtUser) SecurityUtils.getSubject().getPrincipal();
res.put("token",JwtUtil.createJwtTokenByUser(jwtUser));
return res;
}
@RequiresPermissions(“pd”)表示拥有"pd"权限的用户才有访问当前方法的权限。
用postman脚本测试,zhang3(拥有admin角色以及pd权限)可以正常访问,li4(没有pd权限)则会返回异常。
异常返回自行阅读GlobalExceptionController即可,与本帖主题关系不大的代码就不在这里专门说了。
@Slf4j
@RestControllerAdvice
public class GlobalExceptionController {
// 身份验证错误
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity authenticationExceptionHandler(AuthenticationException e) {
log.error("AuthenticationException");
log.error(e.getLocalizedMessage());
Map body=new HashMap();
body.put("status", HttpStatus.FORBIDDEN.value());
body.put("message",e.getLocalizedMessage());
body.put("exception",e.getClass().getName());
body.put("error", HttpStatus.FORBIDDEN.getReasonPhrase());
return new ResponseEntity(body, HttpStatus.FORBIDDEN);//仅是示例,按需求定义
}
//权限验证错误
@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity unauthorizedExceptionHandler(UnauthorizedException e) {
log.error("unauthorizedExceptionHandler");
log.error(e.getLocalizedMessage());
Map body=new HashMap();
body.put("status", HttpStatus.UNAUTHORIZED.value());
body.put("message",e.getLocalizedMessage());
body.put("exception",e.getClass().getName());
body.put("error", HttpStatus.UNAUTHORIZED.getReasonPhrase());
return new ResponseEntity(body, HttpStatus.UNAUTHORIZED);//仅是示例,按需求定义
}
//对应路径不存在
@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity noHandlerFoundExceptionHandler(NoHandlerFoundException e) {
log.error("noHandlerFoundExceptionHandler");
log.error(e.getLocalizedMessage());
Map body=new HashMap();
body.put("message",e.getLocalizedMessage());
body.put("exception",e.getClass().getName());
body.put("error", HttpStatus.NOT_FOUND.getReasonPhrase());
return new ResponseEntity(body, HttpStatus.NOT_FOUND);//仅是示例,按需求定义
}
@ExceptionHandler(Exception.class)
public ResponseEntity exceptionHandler(Exception e) {
log.error("exceptionHandler");
log.error(e.getLocalizedMessage());
log.error(e.getStackTrace().toString());
Map body=new HashMap();
body.put("message",e.getLocalizedMessage());
body.put("exception",e.getClass().getName());
body.put("error", HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase());
return new ResponseEntity(body, HttpStatus.INTERNAL_SERVER_ERROR);//仅是示例,按需求定义
}
}



