前言
在上一篇文章基于SpringBoot整Shiro完成了对shiro的整合,这一篇文章我们不妨对项目进行进一步优化,完成基于JWT优化安全校验框架的优化。
上文我们通过Shiro实现身份认证和权限校验时的逻辑非常简单:
- 调用登录接口。
- 若登录通过,即可直接使用当前登录角色的权限调用相关接口。
但是这种方式也存在着一定的局限性,由于Shiro认证后会将结果缓存在session会话中,对于分布式结构,session不共享的局限性就暴露出来了。
所以为了保证一处认证,处处服务器可用,我们需要整合JWT来完善这个现有的认证逻辑。JWT 是一种可以携带信息的加密串,加密时可以各种信息参数按照某种算法压缩成一个名为token的字符串。解密时,只要提供签名(密钥),就可以将token解析为加密前的数据,不仅保证了安全,在分布式的架构下,这种工作机制也解决了session认证的局限性。
注意事项
本文仅仅对核心逻辑进行介绍,具体代码可参照文章末端笔者所借鉴的源码。
需求
在进行整合之前,我们先来了解一下本次集成过程中JWT所要完成的需求,我们希望某些接口可以按照权限或者角色进行校验。
例如:
- 有些接口需要admin这个角色,xiaowang就是admin那么这个接口就允许他访问。xiaoming是user权限,他就不能访问。
- 有些接口需要normal权限就行,xiaoming是normal权限他就能访问。
- 有些用户虽然是某些角色,但是我们可以手动为其分配特殊权限,例如:xiaoming是user,而user角色只有normal权限,临时某些情况我们希望他能够具备vip权限进行操作,而vip权限只有admin用户才有,我们不希望他变得admin,那么我们就临时分配一个vip权限给他。
表设计
从上文的需求中我们得出本次功能开发需要做到以下几点:
- 首先要设计一张用户表,用户表具备当前用户的账户密码还有角色信息,有时因为一些突发情况我们需要临时为其分配某些权限,所以要在用户表加一个权限字段。
- 用户表的角色,角色都会对应某些权限,所以我们需要一张角色表,为每个角色分配特定权限。
所以我们最终会得到这样一张用户表,他拥有id、用户、密码、角色、特殊临时权限等字段:
CREATE TABLE `user` (
`id` varchar(100) DEFAULT NULL,
`username` varchar(100) DEFAULT NULL,
`password` varchar(100) DEFAULT NULL,
`role` varchar(100) DEFAULT NULL,
`permission` varchar(100) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
我们为这张表初始化某些数据,可以看到xiaoming是普通user具备normal权限,但是我们并没有为其临时分配权限,:
INSERT INTO test_db.`user` (id, username, password, `role`, permission) VALUES('1', 'xiaoming', '123', 'user', '');
INSERT INTO test_db.`user` (id, username, password, `role`, permission) VALUES('2', 'xiaowang', '123', 'admin', '');
然后就是角色表创建了,角色表就比较简单了:
CREATE TABLE `role` (
`id` varchar(100) DEFAULT NULL,
`role` varchar(100) DEFAULT NULL,
`permission` varchar(100) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
然后就是数据插入了可以看到user就是normal权限,admin就是vip权限。
INSERT INTO test_db.`role` (id, `role`, permission) VALUES('1', 'user', 'normal');
INSERT INTO test_db.`role` (id, `role`, permission) VALUES('2', 'admin', 'vip');
Shiro整合JWT
引入依赖
首先自然是引入Shiro和JWT的依赖:
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.3.2</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.2.0</version>
</dependency>
编写JWT工具类
因为我们要集成JWT所以我们首先需要编写JWT生成token的工具类,代码逻辑很简单,就是基于username生成token,还有校验token合法性、根据传入的token获取用户名的方法:
public class JWTUtil {
// 设置过期时间为24h
private static final long EXPIRE_TIME = 24 * 60 * 60 * 1000;
// 密钥
private static final String SECRET = "SHIRO+JWT";
/**
* 生成 token, 5min后过期
*
* @param username 用户名
* @return 加密的token
*/
public static String createToken(String username) throws UnsupportedEncodingException {
Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
Algorithm algorithm = Algorithm.HMAC256(SECRET);
// 基于用户名生成token
return JWT.create()
.withClaim("username", username)
//到期时间
.withExpiresAt(date)
//创建一个新的JWT,并使用给定的算法进行标记
.sign(algorithm);
}
/**
* 校验token合法性
*
* @param token
* @param userName
* @return
*/
public static boolean verify(String token, String userName) {
try {
Algorithm algorithm = Algorithm.HMAC256(SECRET);
//在token中附带了username信息
JWTVerifier verifier = JWT.require(algorithm)
.withClaim("username", userName)
.build();
//验证 token
verifier.verify(token);
return true;
} catch (Exception exception) {
return false;
}
}
/**
* 获得token中的信息,无需secret解密也能获得
*
* @return token中包含的用户名
*/
public static String getUsername(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("username").asString();
} catch (JWTDecodeException e) {
return null;
}
}
}
编写token过滤器
我们希望进行登录时候判断token是否存在,所以我们需要自定义一个过滤器,首先我们需要封装一个类存储token信息
public class JWTToken implements AuthenticationToken {
private String token;
public JWTToken(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
然后编写我们的过滤器,逻辑也很简单,从HTTP协议头中获取到token如果为空则返回false,反之返回true。
public class JWTFilter extends BasicHttpAuthenticationFilter {
private Logger logger = LoggerFactory.getLogger(this.getClass());
/**
* 如果带有 token,则对 token 进行检查,否则直接通过
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws UnauthorizedException {
//判断请求的请求头是否带上 "Token"
if (isLoginAttempt(request, response)) {
//如果存在,则进入 executeLogin 方法执行登入,检查 token 是否正确
try {
executeLogin(request, response);
return true;
} catch (Exception e) {
//token 错误
responseError(response, e.getMessage());
}
}
//如果请求头不存在 Token,则可能是执行登陆操作或者是游客状态访问,无需检查 token,直接返回 true
return true;
}
/**
* 判断用户是否想要登入。
* 检测 header 里面是否包含 Token 字段
*/
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
HttpServletRequest req = (HttpServletRequest) request;
String token = req.getHeader("Token");
return token != null;
}
/**
* 执行登录
*/
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String token = httpServletRequest.getHeader("Token");
JWTToken jwtToken = new JWTToken(token);
// 提交给realm进行登录,如果错误他会抛出异常并被捕获
getSubject(request, response).login(jwtToken);
// 如果没有抛出异常则代表登入成功,返回true
return true;
}
/**
* 对跨域提供支持
*/
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
// 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
/**
* 将非法请求跳转到 /unauthorized/**
*/
private void responseError(ServletResponse response, String message) {
try {
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
//设置编码,否则中文字符在重定向时会变为空字符串
message = URLEncoder.encode(message, "UTF-8");
httpServletResponse.sendRedirect("/unauthorized/" + message);
} catch (IOException e) {
logger.error(e.getMessage());
}
}
}
配置shiro
然后我们就可以开始配置shiro了,首先创建一个名为ShiroConfig的类,添加@Configuration注解,然后添加一个securityManager的bean。
@Bean
public SecurityManager securityManager(CustomRealm customRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 设置自定义 realm.
securityManager.setRealm(customRealm);
/*
* 关闭shiro自带的session,详情见文档
* http://shiro.apache.org/session-management.html#SessionManagement-StatelessApplications%28Sessionless%29
*/
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
securityManager.setSubjectDAO(subjectDAO);
return securityManager;
}
然后就是配置ShiroFilterFactoryBean了,逻辑也很简单:
- 添加自定义过滤器。
- 设置无权限跳转地址。
- 然后所有请求都经过自定义过滤器。
- 认证不通过的请求都请求unauthorized接口。
@Bean
public ShiroFilterFactoryBean factory(SecurityManager securityManager) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
// 添加自己的过滤器并且取名为jwt
Map<String, Filter> filterMap = new LinkedHashMap<>();
//设置我们自定义的JWT过滤器
filterMap.put("jwt", new JWTFilter());
factoryBean.setFilters(filterMap);
factoryBean.setSecurityManager(securityManager);
// 设置无权限时跳转的 url;
factoryBean.setUnauthorizedUrl("/unauthorized/无权限");
Map<String, String> filterRuleMap = new HashMap<>();
// 所有请求通过我们自己的JWT Filter
filterRuleMap.put("/**", "jwt");
// 访问 /unauthorized/** 不通过JWTFilter
filterRuleMap.put("/unauthorized/**", "anon");
factoryBean.setFilterChainDefinitionMap(filterRuleMap);
return factoryBean;
}
编写自定义认证和权限校验逻辑
我们上文SecurityManager需要传入一个CustomRealm,这个CustomRealm就是自定义认证和权限校验逻辑的具体实现,代码如下,可以看到笔者继承了AuthorizingRealm编写的身份认证和权限校验的逻辑。
先来看看身份认证doGetAuthenticationInfo逻辑很简单,将数据库密码和用户传入的进行比对即可。
权限校验doGetAuthenticationInfo同理,将用户的角色和角色对应权限以及特殊权限都存到info信息中。
@Component
public class CustomRealm extends AuthorizingRealm {
private final UserMapper userMapper;
private static Logger logger = LoggerFactory.getLogger(CustomRealm.class);
@Autowired
public CustomRealm(UserMapper userMapper) {
this.userMapper = userMapper;
}
/**
* 必须重写此方法,不然会报错
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JWTToken;
}
/**
* 默认使用此方法进行用户名正确与否验证,错误抛出异常即可。
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
String token = (String) authenticationToken.getCredentials();
logger.info("身份认证方法开始,请求token:{}", token);
// 解密获得username,用于和数据库进行对比
String username = JWTUtil.getUsername(token);
if (username == null || !JWTUtil.verify(token, username)) {
throw new AuthenticationException("token认证失败!");
}
String password = userMapper.getPassword(username);
if (password == null) {
throw new AuthenticationException("该用户不存在!");
}
return new SimpleAuthenticationInfo(token, token, "MyRealm");
}
/**
* 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
logger.info("权限认证开始,请求参数:{}", principals);
String username = JWTUtil.getUsername(principals.toString());
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
//获得该用户角色
String role = userMapper.getRole(username);
//每个角色拥有默认的权限
String rolePermission = userMapper.getRolePermission(username);
//用户的特殊权限
String specpermisson = userMapper.getPermission(username);
Set<String> roleSet = new HashSet<>();
Set<String> permissionSet = new HashSet<>();
//需要将 role, permission 封装到 Set 作为 info.setRoles(), info.setStringPermissions() 的参数
roleSet.add(role);
permissionSet.add(rolePermission);
if (specpermisson != null) {
permissionSet.add(specpermisson);
}
logger.info("当前用户角色:{}", roleSet.toString());
logger.info("当前用户权限:{}", permissionSet.toString());
//设置该用户拥有的角色和权限
info.setRoles(roleSet);
info.setStringPermissions(permissionSet);
return info;
}
}
通用结果响应
为了保证每次相应的结果格式一致,我们这里定义了一下通用结果响应的类,声明不同情况的响应方法。
@Component
public class ResultMap extends HashMap<String, Object> {
public ResultMap() {
}
public ResultMap success() {
this.put("result", "success");
return this;
}
public ResultMap fail() {
this.put("result", "fail");
return this;
}
public ResultMap code(int code) {
this.put("code", code);
return this;
}
public ResultMap message(Object message) {
this.put("message", message);
return this;
}
}
登录控制器
我们上文提到每次需要认证的接口都需要登录,所以我们这里就把登录的逻辑写上。
@RestController
public class LoginController {
private final UserMapper userMapper;
private final ResultMap resultMap;
private static Logger logger = LoggerFactory.getLogger(LoginController.class);
@Autowired
public LoginController(UserMapper userMapper, ResultMap resultMap) {
this.userMapper = userMapper;
this.resultMap = resultMap;
}
@PostMapping("/login")
public ResultMap login(@RequestParam("username") String username,
@RequestParam("password") String password) throws UnsupportedEncodingException {
logger.info("用户登录,username={},password={}", username, password);
//获取数据库中的密码
String dbPwd = userMapper.getPassword(username);
if (StringUtils.isEmpty(dbPwd)) {
logger.warn("用户名:{}不存在", username);
return resultMap.fail().code(401).message("用户名错误");
} else if (!dbPwd.equals(password)) {
logger.warn("用户名:{},密码:{} 输入错误", username, password);
return resultMap.fail().code(401).message("密码错误");
} else {
return resultMap.success().code(200).message(JWTUtil.createToken(username));
}
}
@RequestMapping(path = "/unauthorized/{message}")
public ResultMap unauthorized(@PathVariable String message) throws UnsupportedEncodingException {
return resultMap.success().code(401).message(message);
}
}
自此我们的shiro核心整合就都完成了,我们就可以开始对业务代码进行开发了。
业务功能开发
这里我们就写一个简单的需求接口,如下所示,info接口的RequiresRoles注解中指明只有user和admin角色才可以访问,也就是说未登录的用户不能访问。而getVipInfo接口的注解RequiresPermissions指明只有vip权限可以访问。
@RestController
@RequestMapping("/user")
public class UserController {
private final UserMapper userMapper;
private final ResultMap resultMap;
@Autowired
public UserController(UserMapper userMapper, ResultMap resultMap) {
this.userMapper = userMapper;
this.resultMap = resultMap;
}
/**
* 拥有 user, admin 角色的用户可以访问下面的页面
*/
@GetMapping("/info")
@RequiresRoles(logical = Logical.OR, value = {"user", "admin"})
public ResultMap getMessage() {
return resultMap.success().code(200).message("成功获得信息!");
}
/**
* 拥有 vip 权限可以访问该页面
*/
@GetMapping("/getVipInfo")
@RequiresPermissions("vip")
public ResultMap getVipInfo() {
return resultMap.success().code(200).message("欢迎vip用户!!!");
}
}
业务功能自测
然后我们就可以开始将项目启动自测了,我们将项目启动首先测试一下没有登录的用户是否可以访问user的info接口。
可以看到报了没有访问权限
我们尝试以xiaoming为用户登录,小明是user用户,没有特殊权限,所以他的角色为user,权限只有normal。
登录成功,得到一串token。
然后我们拿着token,去请求info接口。可以看到此时就可以正常访问了。
这一点我们在控制台也能看出custRelam这个域通过token解析到用户名,并得到的user角色和normal权限,接口只需user角色所以当前用户可以访问接口。
我们再用小明的token访问一下getVipInfo接口。可以看到报了没有权限访问的问题。原因很简单,这个接口需要vip权限,xiaoming只有normal权限。
所以我们不妨到数据库中临时给他加一个vip权限。
我们再次访问,可以看到vip接口成功访问了。
小结
自此整个步骤整合完成。基于SpringBoot实现Shiro整合JWT整合步骤不算是困难,大体要遵循一下几个原则和步骤:
- 明确需求,确定表关系。
- 集成JWT。
- 整合Shiro完成自定义的域custRelam实现自定义认证和校验逻辑。
- 编写业务代码。
- 基于需求完成自测。
源码地址
https://gitee.com/fugongliudehua/shiroJWT