引言
我们要知道,框架产生的目标是帮助开发人员解决一些特定场景下的通用问题,为一类共性问题提供一套有效的、稳定的、高效的解决方案,让程序员将主要精力放在业务逻辑的开发上。
没有一个框架能解决一类问题中所有的所有的需求,但是我们可以通过对框架的理解,通过其扩展机制与语言特性,由我们自己通过对实现扩展与业务覆盖帮助我们解决定向业务问题,所以去了解框架的实现原理、需求背景、演变过程时,让我们在使用与构建新的技术架构体系时,都是学习进步不可缺少的一环。
但是...
当没有框架时,如何玩转
试想想,当没有技术框架的支持时,你会如何完成原本被框架主宰的功能?
概要
在网上大部分文章都是教我们怎么去使用一门技术;极少人会去了解为什么这样做,本身在技术发展过程中,太多的底层原理被高度聚合,我们丢弃了很多技术演变的历史过程,这无疑增添了我们的学习成本与难度。
此篇文章不是一篇写框架的教程,只是思考一个特定功能的框架应该具备些什么,然后根据自己的想法进行逐步实现,最终达到需求目的。在后面学习框架的过程中,有了这些思考后,我们就会更有更多的思考空间,不会单单只会按着教程去组装代码,更能有思考的理解作者的用意与自己的想的是否存在些契合的地方...
这篇文章我们以Web认证的为实现目标,思考完成整个需求时,我们需要哪些要素,各个要素的作用,如何协调其工作,最终完成业务目的
要素
一个Web安全框架应该具备哪些要素?
- 主体
携带认证信息的主体,可以是真实用户、一个APP客户端等等。包含了用户基本信息,角色,权限等,贯穿整个上下文中,为资源访问与操作提供检查与支持。 - 代理
浏览器、移动端App、程序,网络访问的客户端,也是实现用户身份认证与鉴权的载体。 - 资源
应用页面、提供数据交换的API。认证与鉴权主要是针对受保护资源,最常见的即是Url、按钮等。 - 认证
校验用户的身份,在web环境中基本上都是基于Filter实现,资源既然是URL,那么基于Servlet开发的Web容器无疑是最佳的选择。 - 授权
校验用户是否有访问资源的权限,基于Filter、AOP实现。如果粗粒度的控制资源的受限访问,Filter足够应对;但AOP可以在方法、参数上在数据实例上进行权限控制。一般系统中,最常见的也就是对资源的访问权限(查看)和操作权限(删除、修改)。 - 凭证
一般指密码,认证时的一种鉴定手段,处理静态的密码串,还有一些动态码,硬件等等。因为身份是透明的,一般情况,认证就是凭证的校验。
主体
身份认证对象。不一定是一个用户,可以是一个客户端、一个终端,一个程序,任何与系统交互的事务都可以看做是一个主体。
- 在 Apache Shrio中是用Subject来表示,其包含了用户的身份,是否认证成功以及是否需要某种权限。
- 而在Spring Security中,则是Authentication接口,通过该接口可以获取身份、凭证、角色权限信息以及其他扩展信息。
主体是一个抽象的概念,因此都被设计成接口,在不同的运行环境下会有不同的表现。在认证阶段我们仅需判断是否能够在上下文中拿到一个已认证的主体。在鉴权阶段则需要判断该主体是否具备有某个角色或权限。
public interface Identity{ Object getPrincipal(); boolean isAuthed(); boolean hasRole(String role); boolean hasPermission(String permission); }
代理
在PC端我们一般通过浏览器来访问我们的web应用,浏览器帮助我们实现资源的请求与响应。每一个资源的请求都是基于Http协议完成,Http是一个应用层无状态网络通信协议,在第一次用户认证成功后,可以通过浏览器保存此次交易的Cookie,与服务器Session结合实现用户与应用的会话保持。
public interface SessionManager{ Session getSession(String sessionKey); }
在MVC模式下,基于Cookie/Session无疑是比较简单的策略,然而在当下比较流行的形式是将前端页面与后端服务进行分离,页面调用后台服务时都是基于api实现,此时cookie已经失效,一般都会基于一串加密的token字符串进行实现身份的重复认证,只不过token包含过多的信息,比如用户的身份,过期时间等。
在单体应用中,session一般基于web容器去实现,基本都是基于内存的,但是在分布式环境下,如何实现集群中的相同web应用间的会话共享,这里就需要通过其他手段去实现session的管理。
资源
资源即是Web应用提供对外的交互数据,可以是一个页面,一组json数据,一个文件等等,在主体访问资源时,首先需要确定当前用户是否已经认证通过;其次检验用户是否有权访问该资源;
认证失败异常:
public abstract class AuthenticationException{ }
拒绝访问异常:
public abstract class AccessDeniedException{ }
认证
前面说到,认证一般基于过滤器实现,在用户访问资源之前,通过过滤器判断用户是否已经认证,如果认证成功,则将已认证的主体设置到上下文中,供其它模块使用,比如鉴权时通过主体拿到权限信息。
public class AuthFilter extends AbstractFilter{ }
认证完成后,我们还需要将一些信息写入到session,在下次访问系统时,通过cookie携带的sessionId判断用户身份,避免每次都取重新验证用户的认证状态。
用户访问系统时,如果通过session核验用户已经认证,则基于session完成AuthFilter的工作。
public class SessionContextFilter extends AbstractFilter{ }
这时cookie就成了用户身份的代表,如果cookie被劫持、篡改等,将出现安全问题。常见的有CSRF攻击
系统登出Filter,删除服务器端保存的用户主体信息。
public class LogoutFilter extends AbstractFilter{ }
身份认证,我们会将过滤器封装的Token的参数进行认证,校验用户信息的准确性,比如用户名、密码等
public interface Authenticator { Identity auth(AuthToken authToken); }
鉴权
基于Filter或AOP实现,相比而言,基于Filter局限于请求或交易,如果系统运行在基于Servlet的Web容器中则可以通过过滤器实现;而基于动态代理的AOP则可以对方法间的调用,更加的通用。
public class AuthInterceptFilter extends AbstractFilter{ }
基于注解结合AOP实现权限判定
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface HasRole { String[] value(); }
我们鉴权对象是方法,主要通过方法注解与用户主体判断用户是否有权限访问方法
public interface MethodAccess { boolean supports(Method method); boolean canAccess(Method method); }
如果通过AOP方式实现,那么类的创建必须是代理类,可以通过JDK动态代理或cglib将鉴权过程注入到目标对象中
凭证
用户凭证的形式有很多,比如密码,指纹,U盘等,以及一些动态码,OTP等,为了保证系统的安全,除了在数据传输阶段,数据加密以及认证过程都做了大量的革新,保证用户凭证的安全,一方面在凭证更新时,保证数据存传输足够安全,如密文传输,https,同时数据保存时是对数据进行加密,如一些加密算法sha256、RSA
public interface PasswordEncoder { String encode(String pwd); boolean matches(String rawPwd,String pwd); }
示例
整体流程
主体流程
系统访问流程
- 用户访问系统 http://localhost:8080/index.html,首先经过SessionContextFilter,尝试从Session获取Identity,如果获取到则保存到上下文,够后面的流程使用
public class SessionContextFilter extends AbstractFilter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpServletRequest = (HttpServletRequest) request; // 从Session获取Identity HttpSession session = httpServletRequest.getSession(false); if(session != null){ // 将Identity保存到上下文 Identity identity = SessionContextHolder.getIdentity(session.getId()); if(identity != null){ IdentityHolder.set(identity); } } chain.doFilter(request,response); // 清理 IdentityHolder.remove(); } }
- Identity不存在,请求经过AuthFilter,直接跳转到登录页面 http://localhost:8080/login.jsp
// 忽略import public class AuthFilter extends AbstractFilter { // 忽略 @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletResponse httpResponse = (HttpServletResponse) response; // 如果请求忽略,直接通过 if(ignoreRequest(httpRequest.getRequestURI())){ chain.doFilter(request,response); return; } // 如果用户已经认证,或者是登录请求,直接通过 if(isAuthed(httpRequest) || loginUrl.equals(httpRequest.getRequestURI())){ chain.doFilter(request,response); return; } // 如果是登录请求 post /login.jsp if(isLogin(httpRequest)){ Identity identity; try { // 将用户名、密码封装成Token AuthToken authToken = buildAuthToken(httpRequest); // 认证Token identity = authenticateToken(authToken); } catch (Exception e) { processAuthException(httpRequest,httpResponse,e); return; } // 将identity保存到上下文 if(identity != null){ HttpSession session = httpRequest.getSession(true); IdentityHolder.set(identity); SessionContextHolder.addIdentity(session.getId(),identity); httpResponse.sendRedirect(loginSuccessUrl); return; } } // 重定向到登录页 httpResponse.sendRedirect(loginUrl); return; } //忽略... }
- 用户输入用户名、密码,提交请求,此时由Authenticator验证Filter中构建的Token,主要核对用户名和密码的正确性
public class DefaultAuthenticator implements Authenticator { // 忽略 @Override public Identity auth(AuthToken authToken) { Object principal = authToken.getPrincipal(); if(!userMap.containsKey(principal)){ throw new AccountNotExistsException(String.format("账号【%s】不存在",principal)); } if(!checkCredentials(authToken)){ throw new CredentialsInvalidException(String.format("账号【%s】凭证无效",principal)); } // 构建Identity WebIdentity identity = new WebIdentity(principal); identity.setRoleIds(new HashSet<>(userMap.get(principal))); // 设置状态为已认证 identity.setAuthed(true); return identity; } }
- 上一步认证成功后,会将Identity保存到上下文与Session中,并重定向到index.html
- 用户访问/properties.html页面,由于上次已经登录,携带了Cookie,在SessionContextFilter中基于Cookie/Session构建Identity并保存到上下文中,在AuthFilter中,不需要需要重新认证
- 进入页面后,页面调用/system/properties交易请求数据资源,改方法需要验证用户是否具有ROLE_ADMIN角色
public class HasRoleMethodAccess implements MethodAccess { private static final Logger LOGGER = LoggerFactory.getLogger(HasRoleMethodAccess.class); @Override public boolean supports(Method method) { return method.getAnnotation(HasRole.class)!=null; } @Override public boolean canAccess(Method method) { HasRole hasRole = method.getAnnotation(HasRole.class); LOGGER.info("角色检查 :{}", Arrays.toString(hasRole.value())); Identity identity = IdentityHolder.get(); if(identity != null){ for (String role : hasRole.value()) { if(!identity.hasRole(role)){ return false; } } return true; } return false; } }
- 退出,调用/logout,经过LogoutFilter,清理上下文Identity信息,使Session失效,回到登录页面
public class LogoutFilter extends AbstractFilter { private static PatternMatcher patternMatcher = new AntPathMatcher("/logout"); @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpServletRequest = (HttpServletRequest) request; if(!patternMatcher.matches(httpServletRequest.getRequestURI())){ chain.doFilter(request,response); return; }else{ //删除session identity HttpSession session = httpServletRequest.getSession(false); if(session != null){ SessionContextHolder.removeIdentity(session.getId()); // 让session失效 session.invalidate(); // 删除上下文Identity IdentityHolder.remove(); } chain.doFilter(request,response); } } }
示例代码
示例代码主要是上诉思想的一个简单实现,完成了一个登录与鉴权执行过程,代码在github地址如下:
每博一问
- 用户的身份认证为什么在Filter完成而不是Servlet中?
- 一套认证的流程不是简单的一个Filter完成,往往会有多个Filter组合实现,这样每个Filter配置都要配置,不是特别麻烦吗?
结束语
编写示例一是为了验证自己的思路,其次在后面阅读源码的过程中,可以有目的的思考,很多框架都是从小的构思到具体细节实现的过程,只不过在细节处理上更加的严谨,同时考虑到稳定性、易用性、扩展性等问题,通过各种设计模式与业务的结合,最终呈现的代码量都会非常庞大,但是能够抓住核心思想,在理解框架本身与自我提升上都是非常有益的。