一、需求
1、前后端分离项目,前端Vue,后端SpringBoot,需要实现后端鉴权;
2、当前端发送请求之后,判断是否登录或用户是否具有相应权限;
3、使用Token实现,不使用Session。
二、实现思路
1、前端Vue的axios设置请求拦截器,在请求头中添加token;
2、后端自定义JWT拦截器,继承BasicHttpAuthenticationFilter,
如果请求头中含有token,则进行认证鉴权操作;
如果请求头中不含有token,禁止访问需要登录之后才能访问的资源;
3、ShiroFilterFactoryBean中自定义拦截url,排除不需要验证的界面(如login、404、401等),其余请求的url,一律拦截至我们自定义的JWTFilter;
4、因为使用JWT验证身份,不存在Session,若没有使用缓冲池,每次鉴权都需要执行登录操作,重新获取身份信息,鉴权前执行登录操作;
5、调用自定义Realm中的doGetAuthorizationInfo,从数据库中获取该用户所对应角色或权限,进行鉴权。
三、Shiro授权原理
授权流程
流程如下:
- 首先调用 Subject.isPermitted*/hasRole*接口,其会委托给 SecurityManager,而 SecurityManager 接着会委托给 Authorizer;
- Authorizer 是真正的授权者,如果我们调用如 isPermitted(“user:view”),其首先会通过 PermissionResolver 把字符串转换成相应的 Permission 实例;
- 在进行授权之前,其会调用相应的 Realm 获取 Subject 相应的角色/权限用于匹配传入的角色/权限;
- Authorizer 会判断 Realm 的角色/权限是否和传入的匹配,如果有多个 Realm,会委托给 ModularRealmAuthorizer 进行循环判断,如果匹配如 isPermitted*/hasRole* 会返回 true,否则返回 false 表示授权失败。
四、关键代码
首先是pom.xml
<!--shiro和springboot整合包-->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-starter</artifactId>
<version>1.6.0</version>
</dependency>
<!--jwt-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
JWTtoken的加密、解析和check已经在我的其他博客写过
1、自定义JWTToken,继承自AuthenticationToke
JWTToken差不多就是Shiro用户名密码的载体。因为我们是前后端分离,服务器无需保存用户状态,简单的实现下AuthenticationToken接口即可,因为项目使用了MD5加密,数据库存储的密码是加盐之后的密码,此处还要保存一下加盐之后的密码,将getCredentials返回为这个加盐之后的密码,因为授权还需要进行一次登录操作,此处又不能使用明文密码,所有只能将加密后的密码再使用相同的盐再加密,再将“加密之后的密码”按照相同的方法加密之后(此处有点绕嘴),再放入SimpleAuthenticationInfo中,在执行Subject.login()。(有更好的方法可以分享一下)
public class JWTToken implements AuthenticationToken {
private String token;
// 用户密码(数据库里存储的用户加密之后的密码)
private String credentials;
// 有参构造
public JWTToken(String token) {
this.token = token;
}
// 无参构造
public JWTToken() {
}
@Override
public Object getPrincipal() {
return this.token;
}
@Override
public Object getCredentials() {
return this.credentials;
}
public void setCredentials(String credentials) {
this.credentials = credentials;
}
}
2、自定义JWT拦截器
我们使用的是 shiro 默认的权限拦截 Filter,而因为JWT的整合,我们需要自定义自己的过滤器 JWTFilter,JWTFilter 继承了 BasicHttpAuthenticationFilter,并部分方法进行了重写。所有的请求都会先经过Filter,所以我们继承官方的BasicHttpAuthenticationFilter,并且重写鉴权的方法。代码执行的流程:
preHandle -> isAccessAllowed -> isLoginAttempt -> executeLogin
public class JWTFilter extends BasicHttpAuthenticationFilter {
/**
* 最后始终返回true
* @param request
* @param response
* @param mappedValue
* @return
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
System.out.println("JWTFilter启动");
//指定请求不经过该过滤器
if(((HttpServletRequest) request).getRequestURI().endsWith("login")){
return true;
}
// 判断请求的请求头是否带token属性,也就是判断用户是否一定登录
if(isLoginAttempt(request, response)){
// 如果请求头中包含token,则执行executeLogin方法进行登入操作,检查token是否正确
System.out.println("用户已经登录");
try {
return executeLogin(request, response);
}catch (Exception e){
e.printStackTrace();
}
}else {
System.out.println("用户没有登录");
try {
Result result = new Result(Code.TOKEN_INVALID, null, "Token为空!");
this.returnErrorMsg(response, result);
}catch (IOException e){
e.printStackTrace();
}
}
return false;
}
/**
* 判断用户是否想要登录
* 在请求头中检查是否有token就行
* @param request
* @param response
* @return
*/
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
// System.out.println("判断用户是否想要登入");
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
return null != httpServletRequest.getHeader("token");
}
/**
* 执行登录
* @param request
* @param response
* @return
* @throws Exception
*/
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
System.out.println("执行登录");
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String token = httpServletRequest.getHeader("token");
try{
getSubject(request, response).login(new JWTToken(token));
return true;
}catch (Exception e){
e.printStackTrace();
Result result = new Result(Code.TOKEN_INVALID, null, "Token失效!");
this.returnErrorMsg(response, result);
return false;
}
}
/**
* 返回给前端自定义错误信息
* @param response
* @param result
* @throws IOException
*/
private void returnErrorMsg(ServletResponse response,Result result) throws IOException {
//响应token为空
response.setContentType("application/json;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
//清空第一次流响应的内容
response.resetBuffer();
//转成json格式
ObjectMapper object = new ObjectMapper();
String asString = object.writeValueAsString(result);
response.getWriter().println(asString);
}
/**
* 因为前后端分离,需对跨域提供支持
* @param request
* @param response
* @return
* @throws Exception
*/
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
// System.out.println("跨域支持");
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);
}
}
上面代码的大致分析:
①、拦截所有的请求,先通过Interceptor解决跨域请求问题;
②、isAccessAllowed方法启动,启动之后,调用isLoginAttempt方法;
③、isLoginAttempt是判断用户是想要执行登录操作还是要授权登录,判断的依据就是请求头里面是否含有token;
④、如果请求头里面含有token,就说明用户已经登录,那就再执行一次登录,用于鉴权;如果没有登录,那就返回前端Result;
3、Shiro全局配置类中增加自定义jwtFilter过滤器,用来拦截并处理携带JWT token的请求
/**
* Shiro配置类
*/
@Configuration
public class ShiroConfig {
/*
* 倒序配置
* 1、先自定义过滤器 MyRealm
* 2、创建第二个DefaultWebSecurityManager,将MyRealm注入
* 3、装配第三个ShiroFilterFactoryBean,将DefaultWebSecurityManager注入,并注入认证及授权规则
* */
//3、装配ShiroFilterFactoryBean,并将 DefaultWebSecurityManager 注入到 ShiroFilterFactoryBean 中
@Bean
public ShiroFilterFactoryBean factoryBean(DefaultWebSecurityManager manager){
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
factoryBean.setSecurityManager(manager);//将 DefaultWebSecurityManager 注入到 ShiroFilterFactoryBean 中
// 自定义拦截url
Map<String, Filter> filterMap = new LinkedHashMap<>();
filterMap.put("jwt", new JWTFilter());
factoryBean.setFilters(filterMap);
//添加默认过滤器
//表示指定登录页面
factoryBean.setLoginUrl("/login");
//未授权页面
//factoryBean.setUnauthorizedUrl("/unauthorized");
//拦截器, 配置不会被拦截的链接 顺序判断
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
//所有匿名用户均可访问到Controller层的该方法下
filterChainDefinitionMap.put("/login", "anon");
filterChainDefinitionMap.put("/checkToken", "anon");
//user表示配置记住我或认证通过可以访问的地址
//filterChainDefinitionMap.put("/remember", "user");
//filterChainDefinitionMap.put("/logout", "logout");
//authc:所有url都必须认证通过才可以访问; anon:所有url都都可以匿名访问
//filterChainDefinitionMap.put("/**", "authc");
//所有url都必须认证通过jwt过滤器才可以访问
filterChainDefinitionMap.put("/**", "jwt");
factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
//注入认证及授权规则
return factoryBean;
}
//2、创建DefaultWebSecurityManager ,并且将 MyRealm 注入到 DefaultWebSecurityManager bean 中
@Bean
public DefaultWebSecurityManager manager(MyRealm myRealm){
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
manager.setRealm(myRealm);//将自定义的 MyRealm 注入到 DefaultWebSecurityManager bean 中
/*
* 关闭shiro自带的session
* 用了jwt的访问认证,所以要把默认session支持关掉
* 即不保存用户登录状态,保证每次请求都重新认证
*/
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
manager.setSubjectDAO(subjectDAO);
return manager;
}
//1、自定义过滤器Realm
@Bean
public MyRealm myRealm(@Qualifier("hashedCredentialsMatcher") HashedCredentialsMatcher matcher){
MyRealm myRealm = new MyRealm();
// 密码匹配器
myRealm.setCredentialsMatcher(matcher);
return myRealm;
}
/**
* 密码匹配器
* @return HashedCredentialsMatcher
*/
@Bean("hashedCredentialsMatcher")
public HashedCredentialsMatcher hashedCredentialsMatcher(){
HashedCredentialsMatcher matcher = new HashedCredentialsMatcher();
// 设置哈希算法名称
matcher.setHashAlgorithmName("MD5");
// 设置哈希迭代次数
matcher.setHashIterations(1024);
// 设置存储凭证(true:十六进制编码,false:base64)
matcher.setStoredCredentialsHexEncoded(true);
return matcher;
}
/**
* SpringShiroFilter首先注册到spring容器
* 然后被包装成FilterRegistrationBean
* 最后通过FilterRegistrationBean注册到servlet容器
* @return
*/
@Bean
public FilterRegistrationBean delegatingFilterProxy() {
FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
DelegatingFilterProxy proxy = new DelegatingFilterProxy();
proxy.setTargetFilterLifecycle(true);
proxy.setTargetBeanName("factoryBean");
filterRegistrationBean.setFilter(proxy);
return filterRegistrationBean;
}
/**
* 注解访问授权动态拦截,
* 不然不会执行Realm中的doGetAuthenticationInfo
* @param manager
* @return
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor( SecurityManager manager){
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(manager);
return authorizationAttributeSourceAdvisor;
}
}
注意点1、关闭Shiro自带的session;
因为用了jwt的访问认证,所以要把默认session支持关掉。即不保存用户登录状态,保证每次请求都重新认证。
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
defaultWebSecurityManager.setSubjectDAO(subjectDAO);
4、配置Realm
/**
* Shiro自定义Realm
*/
public class MyRealm extends AuthorizingRealm {
@Autowired
private UserService userService;
/**
* 授权
* @param principalCollection
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
System.out.println("获取权限信息");
//1.获取身份信息
User pricipal = (User) principalCollection.getPrimaryPrincipal();
//2.根据身份获取、角色权限等信息
ArrayList<String> roles = new ArrayList<>();
roles = ...自己Service获取Role的方法...;
// System.out.println("用户所具有角色:"+roles);
ArrayList<String> permissions = new ArrayList<>();
permissions = ...自己Service获取Permissions的方法...;
System.out.println("用户所具有权限:"+permissions);
//3.将角色、权限添加到授权信息里面
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
info.addRoles(roles);
info.addStringPermissions(permissions);
return info;
}
/**
* 认证
* @param authenticationToken
* @return
* @throws AuthenticationException
*
* 客户端传来的 username 和 password 会自动封装到 token,先根据 username 进行查询,
* 如果返回 null,则表示用户名错误,直接 return null 即可,Shiro 会自动抛出 UnknownAccountException 异常。
* 如果返回不为 null,则表示用户名正确,再验证密码,直接返回 SimpleAuthenticationInfo 对象即可,
* 如果密码验证成功,Shiro 认证通过,否则返回 IncorrectCredentialsException 异常。
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
//System.out.println("启动认证");
String userId;
// 如果这个token是来自JWTToken,说明此次登录目的是授权验证的登录
if(authenticationToken instanceof JWTToken){
JWTToken token = (JWTToken) authenticationToken;
userId = ...自己解析token获取ID的方法...;
User user = userService.getByUsername(token.getUsername());
token.setCredentials(user.getPassword());
if(user != null && user.getStatus().equals(1)){
// 用数据库中已经加密的密码,再用数据库中对应的盐值加密一次
String pwd2 = SaltUtil.encryption(user.getPassword(), user.getSalt());
// 参数列表(实体信息,密码,盐值,realm名称)
return new SimpleAuthenticationInfo(user,pwd2, ByteSource.Util.bytes(user.getSalt()),getName());
}else{
// 此操作是用户真正执行登录操作
UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
//获取存放到数据库中的实体类
User user = userService.getByUsername(token.getUsername());
//System.out.println(user);
if(user != null && user.getStatus().equals(1)){
// 参数列表(实体信息,密码,盐值,realm名称)
return new SimpleAuthenticationInfo(user,user.getPassword(), ByteSource.Util.bytes(user.getSalt()),getName());
}
}
return null;
}
}
4、再需要鉴权的Controller上面,添加注释就可以啦
//需要user角色
@RequiresRoles("user")
//必须同时属于user和admin角色
@RequiresRoles({"user","admin"})
//用户具有index:menu权限才可访问
@RequiresPermissions("index:menu")
五、总结
1、目前没有使用缓存,每次鉴权都要去数据库查Role、查Permissions;
2、鉴权时段的登录操作,因为token中不能封装明文密码,又不能移除加密器,所以要对数据库中已经加密的密码,在此基础上再把这个密码进行一次加密,出现了不必要的资源浪费;
3、虽然实现功能,但是实现过程复杂;
最后,感谢“桃子屁屁”的大力支持。