以前项目中权限认证没有使用安全框架,都是在自定义 filter 中判断是否登录以及用户是否有操作权限的。
最近开了新项目,搭架子时,想到使用安全框架来解决认证问题,spring security 太过庞大,我们的项目不大,所以决定采用 Shiro
什么是 Shiro
Apache Shiro 是一个强大灵活的开源安全框架,可以完全处理身份验证、授权、加密和会话管理。
Realm 是 Shiro 的核心组建,也一样是两步走,认证和授权,在 Realm 中的表现为以下两个方法。
认证:doGetAuthenticationInfo,核心作用判断登录信息是否正确
授权:doGetAuthorizationInfo,核心作用是获取用户的权限字符串,用于后续的判断
Shiro 过滤器
当 Shiro 被运用到 web 项目时,Shiro 会自动创建一些默认的过滤器对客户端请求进行过滤。以下是 Shiro 提供的部分过滤器:
为什么选择 shiro
简单性,Shiro 在使用上较 Spring Security 更简单,更容易理解。
灵活性,Shiro 可运行在 Web、EJB、IoC、Google App Engine 等任何应用环境,却不依赖这些环境。而 Spring Security 只能与 Spring 一起集成使用。
可插拔,Shiro 干净的 API 和设计模式使它可以方便地与许多的其它框架和应用进行集成。Shiro 可以与诸如 Spring、Grails、Wicket、Tapestry、Mule、Apache Camel、Vaadin 这类第三方框架无缝集成。Spring Security 在这方面就显得有些捉衿见肘。
spring boot 整合 shiro
添加 maven 依赖
在项目中引入 shiro 非常简单,我们只需要引入 shiro-pring 就可以了
<!-- SECURITY begin -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.4.0</version>
</dependency>
<!-- SECURITY end -->
shiro 自定义认证 token
AuthenticationToken 用于收集用户提交的身份(如用户名)及凭据(如密码)。Shiro 会调用 CredentialsMatcher 对象的 doCredentialsMatch 方法对 AuthenticationInfo 对象和 AuthenticationToken 进行匹配。匹配成功则表示主体(Subject)认证成功,否则表示认证失败。
Shiro 仅提供了一个可以直接使用的 UsernamePasswordToken,用于实现基于用户名 / 密码主体(Subject)身份认证。UsernamePasswordToken 实现了 RememberMeAuthenticationToken 和 HostAuthenticationToken,可以实现 “记住我” 及“主机验证”的支持。
我们的业务逻辑是每次调用接口,不使用 session 存储登录状态,使用在 head 里面存 token 的方式,所以不使用 session,并不需要用户密码认证。
自定义 token 如下:
/**
* Created by Youdmeng on 2020/6/24 0024.
*/
public class YtoooToken implements AuthenticationToken {
private String token;
public YtoooToken(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
shiro 自定义 Realm
Realm 是 shiro 的核心组件,主要处理两大功能:
认证 我们接收 filter 传过来的 token,并认证 login 操作的 token
授权 获取到登录用户信息,并取得用户的权限存入 roles,以便后期对接口进行操作权限验证
@Slf4j
public class UserRealm extends AuthorizingRealm {
@Autowired
private JedisClusterClient jedis;
/**
* 大坑!,必须重写此方法,不然Shiro会报错
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof YtoooToken;
}
/**
* 授权
*
* @param principals
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
log.info("Shiro权限配置");
String token = principals.toString();
UserDetailVO userDetailVO = JSON.parseObject(jedis.get(token), UserDetailVO.class);
Set<String> roles = new HashSet<>();
roles.add(userDetailVO.getAuthType() + "");
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
info.setRoles(roles);
return info;
}
/**
* 认证
*
* @param token
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
log.info("Shiror认证");
YtoooToken usToken = (YtoooToken) token;
//获取用户的输入的账号.
String sid = (String) usToken.getCredentials();
if (StringUtils.isBlank(sid)) {
return null;
}
log.info("sid: " + sid);
return new SimpleAccount(sid, sid, "userRealm");
}
}
shiro 自定义拦截器
自定义 shiro 拦截器来控制指定请求的访问权限,并登录 shiro 以便认证
我们自定义 shiro 拦截器主要使用其中的两个方法:
isAccessAllowed() 判断是否可以登录到系统
onAccessDenied() 当 isAccessAllowed()返回 false 时,登录被拒绝,进入此接口进行异常处理
/**
* Created by Youdmeng on 2020/6/24 0024.
*/
@Slf4j
public class TokenFilter extends FormAuthenticationFilter {
private String errorCode;
private String errorMsg;
private static JedisClusterClient jedis = JedisClusterClient.getInstance();
/**
* 如果在这里返回了false,请求onAccessDenied()
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String sid = httpServletRequest.getHeader("sid");
if (StringUtils.isBlank(sid)) {
this.errorCode = ResponseEnum.TOKEN_UNAVAILABLE.getCode();
this.errorMsg = ResponseEnum.TOKEN_UNAVAILABLE.getMessage();
return false;
}
log.info("sid: " + sid);
UserDetailVO userInfo = null;
try {
userInfo = JSON.parseObject(jedis.get(sid), UserDetailVO.class);
} catch (Exception e) {
this.errorCode = ResponseEnum.TOKEN_EXPIRE.getCode();
this.errorMsg = ResponseEnum.TOKEN_EXPIRE.getMessage();
return false;
}
if (userInfo == null) {
this.errorCode = ResponseEnum.TOKEN_EXPIRE.getCode();
this.errorMsg = ResponseEnum.TOKEN_EXPIRE.getMessage();
return false;
}
//刷新超时时间
jedis.expire(sid, 30 * 60); //30分钟过期
YtoooToken token = new YtoooToken(sid);
// 提交给realm进行登入,如果错误他会抛出异常并被捕获
getSubject(request, response).login(token);
// 如果没有抛出异常则代表登入成功,返回true
return true;
}
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) {
ResponseMessage result = Result.error(this.errorCode,this.errorMsg);
String reponseJson = (new Gson()).toJson(result);
response.setContentType("application/json; charset=utf-8");
response.setCharacterEncoding("utf-8");
ServletOutputStream outputStream = null;
try {
outputStream = response.getOutputStream();
outputStream.write(reponseJson.getBytes());
} catch (IOException e) {
log.error("权限校验异常",e);
} finally {
if (outputStream != null){
try {
outputStream.flush();
outputStream.close();
} catch (IOException e) {
log.error("权限校验,关闭连接异常",e);
}
}
}
return false;
}
}
配置 ShiroConfig
springboot 中,组件通过 @Bean 的方式交由 spring 统一管理,在这里需要配置 securityManager,shiroFilter,AuthorizationAttributeSourceAdvisor
注入 realm
@Bean
public UserRealm userRealm() {
UserRealm userRealm = new UserRealm();
return userRealm;
}
注入 securityManager
@Bean("securityManager")
public DefaultWebSecurityManager getManager(UserRealm realm) {
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
// 使用自己的realm
manager.setRealm(realm);
/*
* 关闭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);
manager.setSubjectDAO(subjectDAO);
return manager;
}
注入 shiroFilter
此处将自定义过滤器添加到 shiro 中,并配置具体哪些路径,执行 shiro 的那些过滤规则
@Bean("shiroFilter")
public ShiroFilterFactoryBean factory(DefaultWebSecurityManager securityManager) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
// 添加自己的过滤器并且取名为token
Map<String, Filter> filterMap = new HashMap<>();
filterMap.put("token", new TokenFilter());
factoryBean.setFilters(filterMap);
factoryBean.setSecurityManager(securityManager);
/*
* 自定义url规则
* http://shiro.apache.org/web.html#urls-
*/
Map<String, String> filterRuleMap = new HashMap<>();
//swagger
filterRuleMap.put("/swagger-ui.html", "anon");
filterRuleMap.put("/**/*.js", "anon");
filterRuleMap.put("/**/*.png", "anon");
filterRuleMap.put("/**/*.ico", "anon");
filterRuleMap.put("/**/*.css", "anon");
filterRuleMap.put("/**/ui/**", "anon");
filterRuleMap.put("/**/swagger-resources/**", "anon");
filterRuleMap.put("/**/api-docs/**", "anon");
//swagger
//登录
filterRuleMap.put("/login/login", "anon");
filterRuleMap.put("/login/verifyCode", "anon");
// 所有请求通过我们自己的JWT Filter
filterRuleMap.put("/**", "token");
factoryBean.setFilterChainDefinitionMap(filterRuleMap);
return factoryBean;
配置 DefaultAdvisorAutoProxyCreator
解决 在 @Controller 注解的类的方法中加入 @RequiresRole 等 shiro 注解,会导致该方法无法映射请求,导致返回 404。
@Bean
public static DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator(){
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator=new DefaultAdvisorAutoProxyCreator();
/**
* setUsePrefix(false)用于解决一个奇怪的bug。在引入spring aop的情况下。
* 在@Controller注解的类的方法中加入@RequiresRole等shiro注解,会导致该方法无法映射请求,导致返回404。
* 加入这项配置能解决这个bug
*/
defaultAdvisorAutoProxyCreator.setUsePrefix(true);
return defaultAdvisorAutoProxyCreator;
}
配置 AuthorizationAttributeSourceAdvisor 使 doGetAuthorizationInfo()Shiro 权限配置生效
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
在接口中控制权限
使用 RequiresRoles 注解来配置该接口需要的权限
当配置 logical = Logical.OR 时,登录这配置的权限在 1,2,3 中任意一个,既可以成功访问接口
@ApiOperation("任务调度")
@PostMapping("/dispatch")
@RequiresRoles(value = { "1", "2", "3" }, logical = Logical.OR)
public ResponseMessage dispatch(@RequestBody @Valid DispatchVO dispatchVO) {
log.info("任务调度开始 入参:" + JSON.toJSONString(dispatchVO));
try {
service.dispatch(dispatchVO);
return Result.success(ResponseEnum.SUCCESS.getCode(), ResponseEnum.SUCCESS.getMessage());
} catch (RuntimeException e) {
log.error("任务调度失败", e);
return Result.error(ResponseEnum.ERROR.getCode(), e.getMessage());
} catch (Exception e) {
log.error("任务调度失败", e);
return Result.error(ResponseEnum.ERROR.getCode(), ResponseEnum.ERROR.getMessage());
}
}
统一的异常处理
配置全局异常处理
@ControllerAdvice
@Order(value=1)
public class ShiroExceptionAdvice {
private static final Logger logger = LoggerFactory.getLogger(ShiroExceptionAdvice.class);
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler({AuthenticationException.class, UnknownAccountException.class,
UnauthenticatedException.class, IncorrectCredentialsException.class})
@ResponseBody
public ResponseMessage unauthorized(Exception exception) {
logger.warn(exception.getMessage(), exception);
logger.info("catch UnknownAccountException");
return Result.error(ResponseEnum.NOT_AUTHORIZED.getCode(), ResponseEnum.NOT_AUTHORIZED.getMessage());
}
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(UnauthorizedException.class)
@ResponseBody
public ResponseMessage unauthorized1(UnauthorizedException exception) {
logger.warn(exception.getMessage(), exception);
return Result.error(ResponseEnum.NOT_AUTHORIZED.getCode(), ResponseEnum.NOT_AUTHORIZED.getMessage());
}
}
上面使用的 redis 工具
@Bean
@DependsOn("ConfigUtil")
public JedisClusterClient getClient() {
ml.ytooo.redis.RedisProperties.expireSeconds = redisProperties.getExpireSeconds();
ml.ytooo.redis.RedisProperties.clusterNodes = redisProperties.getClusterNodes();
ml.ytooo.redis.RedisProperties.connectionTimeout = redisProperties.getConnectionTimeout();
ml.ytooo.redis.RedisProperties.soTimeout = redisProperties.getSoTimeout();
ml.ytooo.redis.RedisProperties.maxAttempts = redisProperties.getMaxAttempts();
if (StringUtils.isNotBlank(redisProperties.password)) {
ml.ytooo.redis.RedisProperties.password = redisProperties.password;
}else {
ml.ytooo.redis.RedisProperties.password = null;
}
return JedisClusterClient.getInstance();
}
@Data
@Component
@ConfigurationProperties(prefix = "redis.cache")
public class RedisProperties {
private int expireSeconds;
private String clusterNodes;
private int connectionTimeout;
private String password;
private int soTimeout;
private int maxAttempts;
}
依赖工具集:
<dependency>
<groupId>ml.ytooo</groupId>
<artifactId>ytooo-util</artifactId>
<version>3.7.0</version>
</dependency>
https://segmentfault.com/a/1190000023114648
精彩推荐:
一款SQL自动检查神器,再也不用担心SQL出错了,自动补全、回滚等功能大全