不多说了,一个java 安全认证框架 ,类似于Spring-security,两者异同网上多了去,个人比较喜欢shiro ,简单易懂。
json web tokens 的简称
现在微服务架构,前后端分离架构,综合 PC、 APP 、小程序、微信等,采用一个令牌作为登录用户身份的象征,不再用原来的浏览器Session作为登录凭证。
小程序 微信 等应该没有 cookie 、session概念。
采用统一 jwt 一劳永逸解决所有前端过来的请求身份认证问题,减轻服务器内存存储压力。
JWT 的利弊网上分析的够透彻了,这里不多说了。
springboot 精髓之一,不多介绍。如果还不明白springboot的精髓,请务必查看下面大神的微信文章:
https://mp.weixin.qq.com/s/SY7H7EjLN5CpE33k1QKLfA
减轻新项目的配置复杂度,统一配置管理。不会自定义starter的,强烈建议查看下面大神的博客:
https://www.jianshu.com/p/4735fe7ae921
工程名称叫做 shirojwt-spring-boot-starter,先贴出 pom.xml文件内容:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0modelVersion>
<parent>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-parentartifactId>
<version>2.2.6.RELEASEversion>
<relativePath/>
parent>
<groupId>org.guztgroupId>
<artifactId>shirojwt-spring-boot-starterartifactId>
<version>1.0-SNAPSHOTversion>
<properties>
<project.build.sourceEncoding>UTF-8project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8project.reporting.outputEncoding>
<maven.compiler.source>1.8maven.compiler.source>
<maven.compiler.target>1.8maven.compiler.target>
<java.version>1.8java.version>
<java.jwt.version>3.10.2java.jwt.version>
<shiro.spring.version>1.5.2shiro.spring.version>
properties>
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starterartifactId>
dependency>
<dependency>
<groupId>org.springframeworkgroupId>
<artifactId>spring-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-configuration-processorartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-aopartifactId>
dependency>
<dependency>
<groupId>javax.servletgroupId>
<artifactId>javax.servlet-apiartifactId>
dependency>
<dependency>
<groupId>org.apache.shirogroupId>
<artifactId>shiro-springartifactId>
<version>${shiro.spring.version}version>
dependency>
<dependency>
<groupId>com.auth0groupId>
<artifactId>java-jwtartifactId>
<version>${java.jwt.version}version>
dependency>
dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-pluginartifactId>
<configuration>
<source>${java.version}source>
<target>${java.version}target>
<encoding>UTF-8encoding>
configuration>
plugin>
<plugin>
<groupId>org.apache.maven.pluginsgroupId>
<artifactId>maven-source-pluginartifactId>
<configuration>
<attach>trueattach>
configuration>
<executions>
<execution>
<id>attach-sourcesid>
<phase>packagephase>
<goals>
<goal>jar-no-forkgoal>
goals>
execution>
<execution>
<id>install-sourcesid>
<phase>installphase>
<goals>
<goal>jar-no-forkgoal>
goals>
execution>
executions>
plugin>
plugins>
build>
project>
上面都是要用到的jar,其实starter最重要的两个jar依赖:
spring-boot-starter 和 spring-boot-configuration-processor (用于配置文件属性封装)
spring-boot-starter-aop 其实可以不用引入,但如果你在项目中要使用shiro的权限控制注解时,请务必保证你的项目里面有 spring-boot-starter-aop 这个依赖。
spring-web 依赖主要是这个自定义的starter里面用到了一些Request相关内容,不是主要依赖。
shiro-spring 和 java-jwt 主角不用说了
这里说一下总体设计思路,代码全部贴出太多了,文章最后放上 github地址,里面有使用说明 README.md
这里不用多说了,可以查看github上的源码,另外附上使用说明和测试用例的github地址:
shirojwt-spring-boot-starter 源码地址
https://github.com/dwhgygzt/shirojwt-spring-boot-starter
测试工程源码地址
https://github.com/dwhgygzt/myshirojwt-test
代码下载本地,mvn clean install 之后
pom.xml 文件引入如下配置
<dependency>
<groupId>org.guztgroupId>
<artifactId>shirojwt-spring-boot-starterartifactId>
<version>1.0-SNAPSHOTversion>
dependency>
引入配置后,其实 application.yml不用配置任何信息即可启用 shiro jwt,
当然你可以根据下面的常用默认值决定是否配置
如果需要配置不同信息,yml文件配置也十分简单:
shirojwt:
login-url: /api/login
logout-url: /api/logout
jwtIssuer: yourIssuerName
token-header-key: Authorization
下面是一个简单的测试类
@RestController
@RequestMapping("/api")
public class UserInfoController {
// 用于查询用户信息的 service
@Resource
private UserInfoService userInfoService;
@PostMapping("login")
public Map<String, String> login(String userName, String password) {
// 你的登录代码验证逻辑
Map<String, String> loginInfo = userInfoService.login(userName, password);
if (loginInfo == null || loginInfo.isEmpty()) {
BusinessException.create("用户名或密码错误");
}
// 登录验证通过后 生成token给前端
assert loginInfo != null;
loginInfo.put("token", JwtUtil.sign(userName,
loginInfo.get(UserInfoService.passwordKey),
loginInfo.get(UserInfoService.saltKey)));
return loginInfo;
}
@GetMapping("logout")
public String logout() {
Subject subject = SecurityUtils.getSubject();
if (subject != null) {
subject.logout();
}
return "退出成功";
}
}
KEY | VALUE |
---|---|
Authorization | 登录接口获得的token值 |
重写 JwtBussinessService 类即可,覆盖里面几个方法,
java 代码中使用如下:
@Service
public class MyJwtBussinessService extends JwtBussinessService {
private Logger logger = LoggerFactory.getLogger(this.getClass());
public MyJwtBussinessService() {
logger.info("MyJwtBussinessService 初始化");
}
@Override
public AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals, String realmName) {
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
logger.debug("进入 授权 doGetAuthorizationInfo");
logger.debug("the toke is {}", principals.toString());
String userName = JwtUtil.getUserName(principals.toString());
// 模拟从数据库中根据用户名查询出用户
Map<String, String> user = UserInfoService.MYSQL_USER_TABLE.get(userName);
String spit = ",";
// 该用户具有哪些权限
for (String permission : user.get(UserInfoService.permissionsKey).split(spit)) {
authorizationInfo.addStringPermission(permission);
}
// 该用户具有哪些角色
for (String role : user.get(UserInfoService.rolesKey).split(spit)) {
authorizationInfo.addRole(role);
}
return authorizationInfo;
}
@Override
public AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth, String realmName) throws AuthenticationException {
String token = (String) auth.getCredentials();
logger.debug("进入 认证 doGetAuthenticationInfo");
logger.debug("the toke is {}", token);
// token是否过期
Date expiresDate = JwtUtil.getExpiresAt(token);
if (expiresDate == null) {
throw new IncorrectCredentialsException("token 不正确");
} else if (expiresDate.before(new Date())) {
throw new ExpiredCredentialsException("token 过期了");
}
// 验证 token是否有效
String userName = JwtUtil.getUserName(token);
if (userName == null) {
throw new IncorrectCredentialsException("token 不正确");
}
// 验证用户是否存在
Map<String, String> user = UserInfoService.MYSQL_USER_TABLE.get(userName);
if (user == null) {
throw new UnknownAccountException("用户不存在");
}
// 用户最终认证
String password = user.get(UserInfoService.passwordKey);
String salt = user.get(UserInfoService.saltKey);
return new SimpleAuthenticationInfo(token, password, ByteSource.Util.bytes(salt), realmName);
}
@Override
public void onAccessDenied(HttpServletRequest request, HttpServletResponse response, boolean isTokenExists, ShiroException ex) throws IOException {
// 这里的 ShiroException 分为两类 一类认证异常 一类权限检查不通过异常
// AuthenticationException 认证异常
// AuthorizationException 权限检查不通过异常
defaultPrintJson(response, "{\"code\":\"-1\",\"data\":{\"bussinessCode\":\"401\"},\"message\":\"" + ex.getLocalizedMessage() + "\"}");
}
@Override
public String refreshOldToken(String oldToken) {
// 刷新 token
String userName = JwtUtil.getUserName(oldToken);
Map<String, String> user = UserInfoService.MYSQL_USER_TABLE.get(userName);
return JwtUtil.sign(userName, user.get(UserInfoService.passwordKey), user.get(UserInfoService.saltKey));
}
}
默认已经对swagger进行的过滤,可直接访问swagger页面
如果要引入其他Bean 请务必使用懒加载方式,防止自定义的AOP失效,因为ExtraFilterRule所在的配置类会被提前初始化
@Component
public class MyExtraFilterRule extends ExtraFilterRule {
// 请务必使用懒加载方式注入bean
// yourBusinessBean 例如为菜单查询类,查询出所有按钮权限菜单
@Lazy
@Service
private YourBusinessBean yourBusinessBean;
@Override
public void setExtraFilterRule(LinkedHashMap<String, String> filterRuleMap) {
// 不检查某些路径
filterRuleMap.put("/api/init", "noSessionCreation,anon");
// 添加自定义过滤器配置 myTestFilter 就是自己的过滤器
filterRuleMap.put("/api/selectUserInfoByUserName", "noSessionCreation,myTestFilter,jwt,jwtPerms[dd]");
}
}
如果要引入其他Bean 请务必使用懒加载方式,防止自定义的AOP失效,因为ExtraFilter所在的配置类会被提前初始化
@Component
public class MyExtraFilter extends ExtraFilter {
@Override
public void setExtraFilter(LinkedHashMap<String, Filter> filterMap) {
filterMap.put("myTestFilter", new MyTestFilter());
}
}
/**
* 自定义过滤器, 请勿使用 @Bean 或 @Service
*
* admin
*/
public class MyTestFilter extends AuthorizationFilter {
protected Logger logger = LoggerFactory.getLogger(this.getClass());
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
logger.info("没有别的事情,就是表示进过了过滤器 MyTestFilter");
return Boolean.TRUE;
}
}
名称 | 作用 |
---|---|
jwt | jwt认证 |
myCorsFilter | 支持跨域,默认支持 |
jwtPerms | URL 上的权限认证 |
jwtRoles | URL 上的角色认证 |
一般情况下针对基于URL的权限认证,说白了就是按钮权限认证,也即对后台某个Controller方法的权限认证。
所谓权限认证,就是你是否有相应的权限或角色标识才可调用该controller里面的某个方法。
这里做法一般两种, 1. 基于权限注解 2. 基于URL过滤器配置
用法如下:
/**
* 测试 shirojwt
*
* @author admin
*/
@RestController
@RequestMapping("/api")
public class UserInfoController {
// 需要 权限 admin:update 才可访问这个方法
@RequiresPermissions("admin:update")
@PutMapping("updateUser")
public String updateUser(@RequestBody Map<String, String> user) {
userInfoService.updateUser(user);
return "success";
}
// 需要 admin或user角色才能访问这个方法
@RequiresRoles(value = {"admin","user"})
@GetMapping("getUserInfoByUserName")
public Map<String, String> getUserInfoByUserName(String userName) {
return userInfoService.getUserByUserName(userName);
}
}
当用户访问 上面controller层里面任意一个方法时,shiro会调用上文中 doGetAuthorizationInfo
方法,该方法作用就是从数据库或缓存中根据 JWT 取出用户具有的角色和权限,然后Shiro框架会自动判定用户是否具有
访问该方法的权限,如果没有将抛出 UnauthorizedException 异常, 用户可使用全局异常进行捕获反馈给前端。
这里说明一下 在此之前用户已经进过JWT 认证了,如果认证不通过不会到这一步的。
上文已经提过,本starter已经默认注册了 权限角色验证的过滤器,且支持自定义URL过滤配置
名称 | 作用 |
---|---|
jwtPerms | URL 上的权限认证 |
jwtRoles | URL 上的角色认证 |
重复上面的文章 覆写ExtraFilterRule类即可。
默认已经对swagger进行的过滤,可直接访问swagger页面
如果要引入其他Bean 请务必使用懒加载方式,防止自定义的AOP失效,因为ExtraFilterRule所在的配置类会被提前初始化
@Component
public class MyExtraFilterRule extends ExtraFilterRule {
// 请务必使用懒加载方式注入bean
// MenuRoleService 角色菜单权限关系处理service
@Lazy
@Service
private MenuRoleService menuRoleService;
@Override
public void setExtraFilterRule(LinkedHashMap<String, String> filterRuleMap) {
List<Menu> buttons = menuRoleService.listAllButtonMenu();
for( Menu item : buttons ){
// item.getPathUrl() 是按钮对应的后端路径
// item.getPerm() 是按钮应的权限标识,表示这个URL需要该权限标识才可访问
filterRuleMap.put(item.getPathUrl(), "noSessionCreation,jwt,jwtPerms["+ item.getPerm() +"]");
}
}
}
这里如果用户权限认证不通过时候,会调用上文中 MyJwtBussinessService 里面的 onAccessDenied 方法。
此时 ShiroException 为 UnauthorizedException,你可以根据具体的异常类别做出打印或跳转信息给前端。
这里列出 ShiroException 的具体常用的几种子类,以便你做出具体的业务逻辑处理。
类别 | 说明 |
---|---|
NoTokenAuthenticationException | 【jwt验证】 header里面未携带jwt |
ProgramErrorAuthenticationException | 【jwt验证】jwt验证程序500错误 |
ExpiredCredentialsException | 【jwt验证】jwt过期,这个需要你自己认证方法里面抛出 |
ExpiredCredentialsException | 【jwt验证】jwt过期,这个需要你自己认证方法里面抛出 |
IncorrectCredentialsException | 【jwt验证】jwt格式错误,这个需要你自己认证方法里面抛出 |
UnauthorizedException | 【权限验证】 权限认证不通过统一抛出该异常 |
所谓动态 就是可以在管理系统里面随意添加一条或删除一条URL 认证记录,这里暂不建议这样做,
这里非要做其实是要刷新Shiro里面缓存的URL 拦截配置,说穿了就是将里面的一个LinkHashMap清空重新
填充数据。
这里建议开发自行 在认证 和 授权两个方法里面通过redis缓存进行自定义逻辑处理。
例如简单的获取用户是否存在验证逻辑:
@Service
public class CurrentUserServiceImpl implements CurrentUserService {
public CurrentUserVO getCurrentUserFromCacheAndDb(String authToken) {
if (StrUtil.isEmpty(authToken)) {
logger.debug("authToken is null");
BusinessException.create(CommonBusinessCode.AUTHTOKEN_NOTFOUND);
}
CurrentUserVO vo = null;
// 先从缓存里面取 token
RBucket<String> tokenBucket = redissonClient.getBucket(
applicationName + StrUtil.format(SysConstants.REDIS_LOGIN_TOKEN_KEY, SecureUtil.md5(authToken)));
if (!tokenBucket.isExists()) {
logger.debug("authToken {} not in redis", authToken);
BusinessException.create(CommonBusinessCode.AUTHTOKEN_INVALID);
}
// 然后根据token 取用户
RBucket<CurrentUserVO> userBucket = redissonClient.getBucket(
applicationName + StrUtil.format(SysConstants.REDIS_LOGIN_USER_KEY, JwtUtil.getUserName(authToken)));
if (userBucket.isExists()) {
vo = userBucket.get();
CurrentUserContext.remove();
CurrentUserContext.setCurrentUser(vo);
}
// 缓存不存在,从数据库中加载用户信息
if (ObjectUtil.isEmpty(vo)) {
logger.debug("currentUser({}) 获取不到信息 从数据库中查询该用户", JwtUtil.getUserName(authToken));
CurrentUserContext.remove();
sysUserAggregateService.getCurrentUserFromDb(JwtUtil.getUserName(authToken), ExtendNetUtil.getSpringContextRequestIp(), SecureUtil.md5(authToken));
vo = CurrentUserContext.getCurrentUser();
// 放入缓存中
userBucket.set(vo, shiroJwtProperties.getTokenExpireSeconds(), TimeUnit.SECONDS);
}
if (ObjectUtil.isEmpty(vo)) {
logger.debug("currentUser({}) 缓存和数据库中都获取不到用户信息", JwtUtil.getUserName(authToken));
BusinessException.create(CommonBusinessCode.CURRENT_USER_NOTFOUND);
}
return vo;
}
}