shiro的过滤器也是不多的我们可以自定义的方法,它的继承体系如下:
另外UserFilter是继承于AccessControlFilter
1、NameableFilter
NameableFilter给Filter起个名字,如果没有设置默认就是FilterName;还记得之前的如authc吗?当我们组装过滤器链时会根据这个名字找到相应的过滤器实例;
2、OncePerRequestFilter
OncePerRequestFilter用于防止多次执行Filter的;也就是说一次请求只会走一次过滤器链;另外提供enabled属性,表示是否开启该过滤器实例,默认enabled=true表示开启,如果不想让某个过滤器工作,可以设置为false即可。
3、ShiroFilter
ShiroFilter是整个Shiro的入口点,用于过滤需要安全控制的请求进行处理,这个之前已经用过了。
4、AdviceFilter
AdviceFilter提供了AOP风格的支持,类似于SpringMVC中的Interceptor:
boolean preHandle(ServletRequest request, ServletResponse response) throws Exception void postHandle(ServletRequest request, ServletResponse response) throws Exception void afterCompletion(ServletRequest request, ServletResponse response, Exception exception) throws Exception;
preHandler:类似于AOP中的前置增强;在过滤器链执行之前执行;如果返回true则继续过滤器链;否则中断后续的过滤器链的执行直接返回;进行预处理(如基于表单的身份验证、授权)
postHandle:类似于AOP中的后置返回增强;在过滤器链执行完成后执行;进行后处理(如记录执行时间之类的);
afterCompletion:类似于AOP中的后置最终增强;即不管有没有异常都会执行;可以进行清理资源(如接触Subject与线程的绑定之类的);
5、PathMatchingFilter
PathMatchingFilter提供了基于Ant风格的请求路径匹配功能及过滤器参数解析的功能,如“roles[admin,user]”自动根据“,”分割解析到一个路径参数配置并绑定到相应的路径:
boolean pathsMatch(String path, ServletRequest request)
boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception
pathsMatch:该方法用于path与请求路径进行匹配的方法;如果匹配返回true;
onPreHandle:在preHandle中,当pathsMatch匹配一个路径后,会调用opPreHandler方法并将路径绑定参数配置传给mappedValue;
然后可以在这个方法中进行一些验证(如角色授权),如果验证失败可以返回false中断流程;默认返回true;也就是说子类可以只实现onPreHandle即可,无须实现preHandle。如果没有path与请求路径匹配,默认是通过的(即preHandle返回true)。
6、AccessControlFilter
AccessControlFilter提供了访问控制的基础功能;比如是否允许访问/当访问拒绝时如何处理等:
abstract boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception; boolean onAccessDenied(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception; abstract boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception;
isAccessAllowed:表示是否允许访问;mappedValue就是[urls]配置中过滤器参数部分,如果允许访问返回true,否则false;
onAccessDenied:表示当访问拒绝时是否已经处理了;如果返回true表示需要继续处理;如果返回false表示该过滤器实例已经处理了,将直接返回即可。
onPreHandle会自动调用这两个方法决定是否继续处理:
boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception { return isAccessAllowed(request, response, mappedValue) || onAccessDenied(request, response, mappedValue); }
另外AccessControlFilter还提供了如下方法用于处理如登录成功后/重定向到上一个请求:
void setLoginUrl(String loginUrl) //身份验证时使用,默认/login.jsp String getLoginUrl() Subject getSubject(ServletRequest request, ServletResponse response) //获取Subject实例 boolean isLoginRequest(ServletRequest request, ServletResponse response)//当前请求是否是登录请求 void saveRequestAndRedirectToLogin(ServletRequest request, ServletResponse response) throws IOException //将当前请求保存起来并重定向到登录页面 void saveRequest(ServletRequest request) //将请求保存起来,如登录成功后再重定向回该请求 void redirectToLogin(ServletRequest request, ServletResponse response) //重定向到登录页面
比如基于表单的身份验证就需要使用这些功能。
(1)如果我们想进行访问访问的控制就可以继承AccessControlFilter
例如:Shiro提供roles拦截器,其验证用户拥有所有角色,没有提供验证用户拥有任意角色的拦截器。
public class AnyRolesFilter extends AccessControlFilter { private String unauthorizedUrl = "/unauthorized.jsp"; private String loginUrl = "/login.jsp"; protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception { String[] roles = (String[])mappedValue; if(roles == null) { return true;//如果没有设置角色参数,默认成功 } for(String role : roles) { if(getSubject(request, response).hasRole(role)) { return true; } } return false;//跳到onAccessDenied处理 } @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { Subject subject = getSubject(request, response); if (subject.getPrincipal() == null) {//表示没有登录,重定向到登录页面 saveRequest(request); WebUtils.issueRedirect(request, response, loginUrl); } else { if (StringUtils.hasText(unauthorizedUrl)) {//如果有未授权页面跳转过去 WebUtils.issueRedirect(request, response, unauthorizedUrl); } else {//否则返回401未授权状态码 WebUtils.toHttp(response).sendError(HttpServletResponse.SC_UNAUTHORIZED); } } return false; } }
流程:
1、首先判断用户有没有任意角色,如果没有返回false,将到onAccessDenied进行处理;
2、如果用户没有角色,接着判断用户有没有登录,如果没有登录先重定向到登录;
3、如果用户没有角色且设置了未授权页面(unauthorizedUrl),那么重定向到未授权页面;否则直接返回401未授权错误码。
(2)FormAuthenticationFilter基于表单登录拦截器
public class FormLoginFilter extends PathMatchingFilter { private String loginUrl = "/login.jsp"; private String successUrl = "/"; @Override protected boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception { if(SecurityUtils.getSubject().isAuthenticated()) { return true;//已经登录过 } HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; if(isLoginRequest(req)) { if("post".equalsIgnoreCase(req.getMethod())) {//form表单提交 boolean loginSuccess = login(req); //登录 if(loginSuccess) { return redirectToSuccessUrl(req, resp); } } return true;//继续过滤器链 } else {//保存当前地址并重定向到登录界面 saveRequestAndRedirectToLogin(req, resp); return false; } } private boolean redirectToSuccessUrl(HttpServletRequest req, HttpServletResponse resp) throws IOException { WebUtils.redirectToSavedRequest(req, resp, successUrl); return false; } private void saveRequestAndRedirectToLogin(HttpServletRequest req, HttpServletResponse resp) throws IOException { WebUtils.saveRequest(req); WebUtils.issueRedirect(req, resp, loginUrl); } private boolean login(HttpServletRequest req) { String username = req.getParameter("username"); String password = req.getParameter("password"); try { SecurityUtils.getSubject().login(new UsernamePasswordToken(username, password)); } catch (Exception e) { req.setAttribute("shiroLoginFailure", e.getClass()); return false; } return true; } private boolean isLoginRequest(HttpServletRequest req) { return pathsMatch(loginUrl, WebUtils.getPathWithinApplication(req)); } }
onPreHandle主要流程:
1、首先判断是否已经登录过了,如果已经登录过了继续拦截器链即可;
2、如果没有登录,看看是否是登录请求,如果是get方法的登录页面请求,则继续拦截器链(到请求页面),否则如果是get方法的其他页面请求则保存当前请求并重定向到登录页面;
3、如果是post方法的登录页面表单提交请求,则收集用户名/密码登录即可,如果失败了保存错误消息到“shiroLoginFailure”并返回到登录页面;
4、如果登录成功了,且之前有保存的请求,则重定向到之前的这个请求,否则到默认的成功页面。
(3)CasFilter用于客户端从cas server获取到ticket后回调应用发起请求的时候被拦截,应用将ticket发送到cas server做验证
(4)BasicHttpAuthenticationFilter通过Basic认证的方式登录(例如JWT token方案)
JWTToken
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; } }
BasicHttpAuthenticationFilter
public class JWTFilter extends BasicHttpAuthenticationFilter { private Logger LOGGER = LoggerFactory.getLogger(this.getClass()); /** * 判断用户是否想要登入。 * 检测header里面是否包含Authorization字段即可 */ @Override protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) { HttpServletRequest req = (HttpServletRequest) request; String authorization = req.getHeader("Authorization"); return authorization != null; } /** * */ @Override protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpServletRequest = (HttpServletRequest) request; String authorization = httpServletRequest.getHeader("Authorization"); JWTToken token = new JWTToken(authorization); // 提交给realm进行登入,如果错误他会抛出异常并被捕获 getSubject(request, response).login(token); // 如果没有抛出异常则代表登入成功,返回true return true; } /** * 这里我们详细说明下为什么最终返回的都是true,即允许访问 * 例如我们提供一个地址 GET /article * 登入用户和游客看到的内容是不同的 * 如果在这里返回了false,请求会被直接拦截,用户看不到任何东西 * 所以我们在这里返回true,Controller中可以通过 subject.isAuthenticated() 来判断用户是否登入 * 如果有些资源只有登入用户才能访问,我们只需要在方法上面加上 @RequiresAuthentication 注解即可 * 但是这样做有一个缺点,就是不能够对GET,POST等请求进行分别过滤鉴权(因为我们重写了官方的方法),但实际上对应用影响不大 */ @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { if (isLoginAttempt(request, response)) { try { executeLogin(request, response); } catch (Exception e) { response401(request, response); } } 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); } /** * 将非法请求跳转到 /401 */ private void response401(ServletRequest req, ServletResponse resp) { try { HttpServletResponse httpServletResponse = (HttpServletResponse) resp; httpServletResponse.sendRedirect("/401"); } catch (IOException e) { LOGGER.error(e.getMessage()); } } }
AuthorizingRealm
public class MyRealm extends AuthorizingRealm { private static final Logger LOGGER = LogManager.getLogger(MyRealm.class); private UserService userService; @Autowired public void setUserService(UserService userService) { this.userService = userService; } /** * 在AuthorizingRealm传入一个token的时候shiro都会去验证这个token是不是被shiro所支持,只有这个地方返回true才会去进行后面认证鉴权操作。 * AuthorizingRealm的AuthenticationToken默认实现是UsernamePasswordToken,所以去验证是不是UsernamePasswordToken它的子类 * 可是这里是我们自定义的AuthenticationToken实现,所以要重写这个方法。 */ @Override public boolean supports(AuthenticationToken token) { return token instanceof AuthenticationToken; } /** * 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的 */ @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username = JWTUtil.getUsername(principals.toString()); UserBean user = userService.getUser(username); SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo(); simpleAuthorizationInfo.addRole(user.getRole()); Set<String> permission = new HashSet<>(Arrays.asList(user.getPermission().split(","))); simpleAuthorizationInfo.addStringPermissions(permission); return simpleAuthorizationInfo; } /** * 默认使用此方法进行用户名正确与否验证,错误抛出异常即可。 */ @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException { String token = (String) auth.getCredentials(); // 解密获得username,用于和数据库进行对比 String username = JWTUtil.getUsername(token); if (username == null) { throw new AuthenticationException("token invalid"); } UserBean userBean = userService.getUser(username); if (userBean == null) { throw new AuthenticationException("User didn't existed!"); } if (! JWTUtil.verify(token, username, userBean.getPassword())) { throw new AuthenticationException("Username or password error"); } return new SimpleAuthenticationInfo(token, token, "my_realm"); } }
JWTUtil
public class JWTUtil { // 过期时间60分钟 private static final long EXPIRE_TIME = 60*60*1000; /** * 校验token是否正确 * @param token 密钥 * @param secret 用户的密码 * @return 是否正确 */ public static boolean verify(String token, String username, String secret) { try { Algorithm algorithm = Algorithm.HMAC256(secret); JWTVerifier verifier = JWT.require(algorithm) .withClaim("username", username) .build(); DecodedJWT jwt = 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; } } /** * 生成签名,5min后过期 * @param username 用户名 * @param secret 用户的密码 * @return 加密的token */ public static String sign(String username, String secret) { try { Date date = new Date(System.currentTimeMillis()+EXPIRE_TIME); Algorithm algorithm = Algorithm.HMAC256(secret); // 附带username信息 return JWT.create() .withClaim("username", username) .withExpiresAt(date) .sign(algorithm); } catch (UnsupportedEncodingException e) { return null; } } }
过程总结:
当浏览器带着token过来,被JWTFilter拦截,从请求中拿到token,执行登录操作,这是会转给AuthorizingRealm,它的认证方法doGetAuthenticationInfo会对token进行校验,校验通过后获取token中的用户名信息;
当需要鉴权的时候doGetAuthorizationInfo会由用户名信息从数据库去查询角色和权限。返回进行比对。