BS系统的登录鉴权流程演变
- 1 基础知识
- 1.1 Http Cookie
- 1.2 重定向与前端路由Vue-router
- 1.2.1 后端重定向
- 1.2.2 Vue-router
- 1.3.JWT简介
- 1.4 Spring-Security
- 1.4.1 过滤器链[24]
- 1.4.3 DelegationFilterProxy的实例化和拦截配置
- 1.4.4 在项目中使用Spring Security
- 1.4.5 用户认证
- 2. 登录鉴权方式演变
- 2.1前后端不分离的登录鉴权流程#
- 2.2前后端分离后的登录鉴权流程#
- 2.2.1 单应用系统
- 2.2.2 多个微服务的系统
1 基础知识
用户登录是使用指定用户名和密码登录到系统,以对用户的私密数据进行访问和操作。在一个有登录鉴权的BS系统中,通常用户访问数据时,后端拦截请求,对用户进行鉴权,以验证用户身份和权限。用户名、密码等身份信息只需要在登录时输入一次,然后通过前后端的配合,在之后的每次访问都不用再输入了,通常的方案是将身份标识存在cookie中。
实际的登录方案通常较为复杂。一方面需要了解系统的整体架构,包括前端的架构,然后按需设计不同的登录方案;二是需要考虑安全漏洞。我接触过几个系统,从简单的系统到复杂的,在这里把它们的登录方案介绍一下。
在介绍具体的登录方案前,先介绍下登录相关的基础知识。
1.1 Http Cookie
Cookie是由Web服务器向Web浏览器发送的一小段字符串,此后的所有浏览器对服务端的访问都会携带这个字符串。Cookie由Netscape发明,它使得保持HTTP请求的状态(Http协议是无状态协议)变得容易,服务端可以向Cookie中存入任意的信息。Cookie最常见用于已登录用户的鉴权,用户不用每次请求访问时都在页面进行登录。Cookie也有其它用途,比如用于存储购物车列表[3]。
Cookie的使用是通过Http头set-cookie和cookie实现的。在接收到Http请求后,服务端可以向浏览器发送一个或多个Set-Cookie应答头。浏览器会自动存储cookie并在此后的浏览器对服务端的请求中携带Cookie请求头[1]。
服务端向浏览器发送的应答头:
HTTP/2.0 200 OK
Content-Type: text/html
Set-Cookie: yummy_cookie=choco
Set-Cookie: tasty_cookie=strawber浏览器自动将cookie保存,在此后每次浏览器向服务的请求都会自动携带该cookie:GET /sample_page.html HTTP/2.0
Host: www.example.org
Cookie: yummy_cookie=choco; tasty_cookie=strawberry
服务端向浏览器发送应答头的java代码实现如Demo1。你可以为cookie设置存活时间,指定Cookie的域名和路径。Cookie的默认存活时间为浏览器会话结束;设置存活时间后,会话结束不会影响cookie的存活;浏览器会话结束的场景比如关闭浏览器窗口。Cookie的默认所属路径是“/项目路径/相对路径”的上一层路径;当请求路径为Cookie所属的路径及其子路径时,才会携带cookie。还可以为Cookie设置Same-Site、HttpOnly等属性[2],它们与Cookie使用的安全性有关。
public class CookieTestServlet extends HttpServlet {protected void doGet(HttpServletRequest request, HttpServletResponse response)throws ServletException, IOException {// 创建cookie对象Cookie yummy = new Cookie("yummy_cookie", "choco");Cookie tasty = new Cookie("tasty_cookie", "strawberry");//默认存活时间为浏览器会话结束;设置存活时间后,会话结束不会影响cookie的存活;浏览器会话结束的场景比如关闭浏览器窗口tasty.setMaxAge(3600);//默认路径是“/项目路径/相对路径”的上一层路径;当请求路径为设置的路径及其子路径时,才会携带cookie;tasty.setPath("/test");//不设置Domain时,Domain的默认值为当前请求的域名(比如localhost、www.example.org等)//将cookie返回给浏览器,通过应答头set-cookieresponse.addCookie(yummy);response.addCookie(tasty);}
}
Demo1 创建Cookie并设置其常用属性的java代码实现
在服务端设置了Cookie的存活时间和路径后,服务端向浏览器发送的应答头如下:
HTTP/2.0 200 OK
Content-Type: text/html
Set-Cookie:tasty_cookie=strawberry; Max-Age=3600; Expires=Thu, 21-Sep-2023 08:33:24 GMT; Path=/test
Set-Cookie:yummy_cookie=choco
1.2 重定向与前端路由Vue-router
1.2.1 后端重定向
早期的系统是前后端不分离的。一个典型的系统使用SSM+JSP的架构。未登录的用户访问系统页面时,会通过后端重定向到登录页,如Demo2。登录时通常将用户名、密码通过form表单提交到后台,后台登录校验不通过时,也会重定向到登录页。
//权限过滤器
public class PermissionFilter implements Filter {public void doFilter(ServletRequest _request, ServletResponse _response,FilterChain chain) throws IOException, ServletException {//如果未登录,重定向到登录页if (!checkLogin(request, response)){response.sendRedirect(request.getContextPath() + "/login.jsp")}chain.doFilter(request, response);}
}
Demo2 权限过滤器的简单实现
1.2.2 Vue-router
在系统的前后端分离后,一个比较常见Web系统,前端使用Vue+Nodejs,后端使用SpringBoot+Spring+Mybatis。在用户认证鉴权业务中,前端应用可独立地提供页面的访问和实现页面间的跳转,后端实现用户认证鉴权的逻辑并提供接口。用户未登录访问系统页面时,页面跳转通常是通过Vue-router实现的。如Demo3,在router的全局前置守卫(router.berforeEach)中,判断用户是否已登录,如果未登录,则跳转(通过路由导航)到登录页。
router.beforeEach((to, from, next) => {//判断是否已登录if (getToken()) {next() //进入管道中的下一个钩子}else{next(`/login`) //跳转到登录页}
}
Demo3 使用Vue-router.beforeEach进行用户是否登录的判断
对于大多数单页应用,Vue都推荐使用官方的Vue-router。这是由于前端应用的业务功能越来越复杂,单页应用(SPA)成为前端应用的主流形式。Vue-router通过管理URL,实现URL和组件的对应,以及通过URL进行组件之间的切换。可以参考相关博客中的案例,进行Vue-router的安装和简单使用[5]。使用Vue-router,通过改变URL,在不重新请求页面的情况下,就可以更新页面视图。“更新视图但不重新请求页面”是前端路由原理的核心之一,目前在浏览器环境中这一功能的实现主要有两种方式[6]:
利用URL中的hash(“#”)
利用History interface在 HTML5中新增的方法。
如Demo4,在vue-router中是通过mode这一参数控制路由的实现模式的,mode值为“hash“表示第一种方式,值为”history“表示第二种方式。可以使用 router.beforeEach 注册一个全局前置守卫。当一个路由导航触发时,全局前置守卫按照创建顺序调用。每个守卫方法接收三个参数[16]:
- to: Route: 即将要进入的目标 路由对象
- from: Route: 当前导航正要离开的路由
- next: Function: 一定要调用该方法来 resolve 这个钩子。执行效果依赖 next 方法的调用参数。
1.next(): 进行管道中的下一个钩子。如果全部钩子执行完了,则导航的状态就是 confirmed (确认的)。
2.next(false): 中断当前的导航。如果浏览器的 URL 改变了 (可能是用户手动或者浏览器后退按钮),那么 URL 地址会重置到 from 路由对应的地址。
3.next(‘/’) 或者 next({ path: ‘/’ }): 跳转到一个不同的地址。当前的导航被中断,然后进行一个新的导航。你可以向 next 传递任意位置对象,且允许设置诸如 replace: true、name: ‘home’ 之类的选项以及任何用在 router-link 的 to prop 或 router.push 中的选项。
4.next(error): (2.4.0+) 如果传入 next 的参数是一个 Error 实例,则导航会被终止且该错误会被传递给 router.onError() 注册过的回调。
确保要调用 next 方法,否则钩子就不会被 resolved。
export default new Router({// mode: 'hash',mode: 'history',scrollBehavior: () => ({ y: 0 }),routes: constantRouterMap
})
Demo4 创建Vue-router时指定mode为history
1.3.JWT简介
JSON Web Token (JWT) 是一个开放的标准(RFC 7519[8])。它定义了严谨且独立的方式,在多方服务间以JSON对象的格式安全地传递信息。数字签名使传递的信息可验证可信任。JWT签名方式有使用一个secret的HMAC算法和使用公钥/私钥的RSA或ECDSA算法等。
JWT包含head、payload和signature三个部分。比如signature的签名方式使用的是公钥/私钥的RSA算法。payload中一般包含用户名和权限等信息,可以直接从token中获取这些信息。token还有一些其它特性,signature使用公钥解密后的值与head和playload的值做对比是否一致,可以判断token是否被篡改。从系统层面讲,只有对token的签名方拥有私钥。
JWT是基于token鉴权标准之一,常用的标准还有OAuth[9]。token是身份验证过程中用到的令牌,他是验证用户身份和资源权限的临时密钥。一个有效的token允许用户对在线的服务和web应用进行访问直至token过期。这提供了便利,用户不用每次都重新进行登录认证,就可以继续访问资源。这与cookie中的sessionId有类似之处。
JWT的构成#
JSON Web Token(JWT)包含Header、Payload和Signature3个部分,3个部分以点号(.)隔开,他的格式可以表示为xxxxx.yyyyy.zzzzz。
1.4 Spring-Security
Spring Security框架提供了身份认证、权限控制和安全漏洞防护等功能。它在保护spring应用方面是实际上的标准,为保护命令式和反应式应用程序提供一流的支持。
1.4.1 过滤器链[24]
Spring Security的servlet实现是基于servlet过滤器的。如图1中的图①,当客户端向应用发送请求后,servlet容器依据请求的URL创建FilterChain,FilterChain中包含Filter实例和处理HttpServetRequest的Servlet。
Spring提供了DelegatingFilterProxy这个过滤器,它将Servlet容器的生命周期和Spring中的ApplicationContext连接起来。Servlet容器允许使用自己的标准注册Filter实例,但无法识别Spring中定义的beans。你可以通过Servlet容器的机制注册DelegatingFilterProxy并将所有的工作委托给实现Filter接口的Spring Bean。图1中的②展示了DelegatingFilterProxy与FilterChain和Spring的Filter实例的关系。DelegatingFilterProxy从ApplicationContext中搜寻Bean Filter0并调用该Bean Filter0,DelegatingFilterProxy的伪代码实现如Demo5。
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
Filter delegate = getFilterBean(someBeanName);
delegate.doFilter(request, response);
}
Demo5 DelegatingFilterProxy将工作委托给Spring Beans的伪代码
Spring Security对Servlet的支持都包含在FilterChainProxy中。FilterChainProxy是一个由Spring Security提供的特殊的Filter,它允许通过SecurityFilterChain将工作委托给许多Filter实例。FilterChainProxy是一个Bean,通常包装在DelegatingFilterProxy中。图1中的③展示了FilterChainProxy的角色。
图1 过滤器链的结构:①Servlet容器中的过滤器链; ②Spring中的DelegatingFilterProxy; ③FilterChainProxy; ④SecurityFilterChain; ⑤多SecurityFilterChain。
1.4.3 DelegationFilterProxy的实例化和拦截配置
DelagtingFilterProxy的初始化和拦截配置在容器启动的时候就完成了。
SpringServletContainerInitializer是ServletContainerInitializer的实现类,且使用@HandlesTypes注解。当容器启动后,会调用SpringServletContainerInitializer的onStartup方法,收集WebApplicationInitializer的子类,并循环调用这些子类的onStartup方法。AbstractSecurityWebApplicationInitializer是WebApplicationInitializer的子类,它的onStartup方法被调用,对DelegationFilterProxy进行实例化并配置拦截路径。图2展示了从容器启动到DelegationFilterProxy完成实例化和拦截配置的流程
1.4.4 在项目中使用Spring Security
现在的项目大多都是前后端分离的。以开源项目eladmin[26]为例进行说明。该项目前端使用Vue+Nodejs,后端使用SpringBoot+Spring+Mybatis,基于Spring Security进行用户认证、鉴权授权。项目中引入Spring Security后,进行了用户认证、鉴权授权的功能开发。功能开发主要分为两块内容。
1.4.5 用户认证
用户在登录时进行用户认证。如图3,用户登录时,前端发送登录请求到后端的登录接口。登录接口的逻辑实现如Demo8,其中调用了Spring Security中的方法。实际用户认证逻辑是在Spring Security框架中实现。Spring Security会调用接口UserDetailService的loadUserByUsername方法获取系统中的用户,需要在系统中添加接口UserDetailService的实现类UserDetailServiceImpl,如Demo9。在获取系统的用户后,会调用Spring Security中的DaoAuthenticationProvider的additionalAuthenticationChecks方法,该方法对比登录密码与系统中的密码是否一致,如果一致则用户认证成功,否则认证失败。需要开发者实现的是后台登录接口和获取用户信息的实现类UserDetailServiceImpl,用户认证的逻辑是由Spring Security框架实现的。用户登录接口的url为“/login”,该接口在过滤器配置中配置了所有用户(包括未登录用户)都可以访问。
@AnonymousPostMapping(value = "/login")
public ResponseEntity<Object> login(@Validated @RequestBody AuthUserDto authUser, HttpServletRequest request) throws Exception {// 密码解密String password = RsaUtils.decryptByPrivateKey(RsaProperties.privateKey, authUser.getPassword());UsernamePasswordAuthenticationToken authenticationToken =new UsernamePasswordAuthenticationToken(authUser.getUsername(), password);//调用spring-security框架中的方法,进行用户认证Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);// 生成令牌String token = tokenProvider.createToken(authentication);// 将令牌token存入redis中// 将令牌token应答到前端
}
Demo8 用户登录接口代码实现
@Service("userDetailsService")
public class UserDetailsServiceImpl implements UserDetailsService {@Overridepublic JwtUserDto loadUserByUsername(String username) {user = userService.findByName(username);jwtUserDto = new JwtUserDto(user,dataService.getDeptIds(user),roleService.mapToGrantedAuthorities(user));return jwtUserDto;}
}
Demo9 系统中添加了UserDetailsService的实现类UserDetailsServiceImpl
2. 登录鉴权方式演变
登录鉴权方式是随着前后端架构的变化而变化的。早期的系统是前后端不分离的。通常前端是freemaker/velocity/jsp+html。后端是SSH或SSM。
后来Vue等前端框架的兴起,使得前后端得以分离。前端是Vue+nodejs,后端是SSM或SpirngBoot。SpringBoot大大简化了应用的配置。
再后来微服务SpringCloud兴起,它包含网关、配置中心、注册中心等组件。多个微服务的登录鉴权实现和单应用系统又略有差异。
2.1前后端不分离的登录鉴权流程#
早期的系统是前后端不分离的。一个典型的系统使用SSM+JSP的架构,技术栈为SpringMVC + Spring + Mybatis + JSP+ Apache + Weblogic。系统的架构图如图5所示。系统的登录鉴权流程如图6所示。系统未登录时,访问系统的请求将被用户权限过滤器拦截,首次进入权限过滤器会生成会话Session,然后通过后端重定向跳转到登录页。在登录页中,用户填写好用户名密码后,提交form表单,请求后台登录接口,后台进行登录校验,登录成功后将用户名、密码等用户信息存入Session中。在后面的每次访问后端接口时,根据携带cookie的jessionId就可以从Session中获取用户信息,如果用户名不为空,就说明已认证过,然后可正常访问后端接口,不用每次访问都进行用户认证。用户登录认证失败后,会跳转到错误页。
图5 一个前后端不分离的典型系统的架构图
2.2前后端分离后的登录鉴权流程#
2.2.1 单应用系统
在系统的前后端分离后,一个比较常见Web系统,前端使用Vue+Nodejs,后端使用SpringBoot+Spring+Mybatis。以开源项目eladmin[26]为例进行说明。系统的架构图如图7所示。系统的用户认证流程如图8所示,在用户认证流程中,前端应用可独立地提供页面的访问和实现页面间的跳转,后端实现用户认证的逻辑并提供接口。访问系统的请求通过Vue-Router导航到相应页面,路由导航会触发全局前置守卫的调用。在全局守卫的逻辑中,如果用户未登录,路由会导航到登录页。用户填好用户名和密码进行登录,向后台发起登录的ajax请求,后台会校验用户名密码,校验逻辑是基于SpringSecurity实现的,如果校验通过,则生成JWT类型的token,应答到浏览器。浏览器端会保存token到cookie中,并创建后端接口请求拦截器,拦截请求并将cookie中的token放到请求头Authentication中。请求到达后端服务后,后端的用户权限过滤器会判断Authentication中的token是否已登录过(判断在redis中是否存在),并基于SpringSecurity进行鉴权,如果鉴权通过,则正常访问接口。不用每次访问后端接口都进行用户认证。如果登录失败,则应答错误状态码和错误信息,浏览器会在页面进行错误提示。
2.2.2 多个微服务的系统
后来微服务逐渐兴起,SpringCloud是热门的技术之一。SpringCloud包含一系列组件,包括Eureka、Ribbon、Zuul、Feign和Config Server等,方便进行微服务的管理、调用和配置。一个比较常见的Web系统,前端使用Vue+Nodejs,后端使用SpringBoot+SpringCloud+Spring+Mybatis。系统的架构图如图9所示。系统的用户认证流程如图10所示。与单应用系统的用户认证流程相比,主要有2点不同。一是用户认证逻辑会放在独立的鉴权微服务中。二是不是每个包含业务接口的微服务都放一个用户权限过滤器,而将过滤器放在网关微服务中。如图10的架构图,每个后端请求都会经过网关,在网关中放入用户权限过滤器是合适的。在实际业务中,网关作为后端微服务的唯一入口,后端微服务则放在内网中,不能不通过网关直接访问后端微服务的接口。用户认证成功后,后端会应答set-cookie:token=aaaaa.bbbb.ccccc到浏览器,浏览器将token存入cookie中,后续的接口访问请求都会携带该cookie,请求经过权限过滤器时,过滤器将cookie中的token取出,判断该token是否已登录过(判断在redis中是否存在),并基于SpringSecurity进行鉴权,如果鉴权通过,则正常访问接口,不用每次都进行用户认证。不过,token放在Cookie中是不建议的,建议放在请求头Authrization中,详细可参考1.4.3节。