在前一篇学习了前后端不分离模式下的登录回调和注销登录,接下来开始学习前后端分离模式下的登录回调和注销登录。请注意,前后端分离模式下,前后端是通过json来进行数据交互,因此和之前需要采用不同的处理方式。还有前后端分离模式下认证是使用传统的session还是采用像JWT一样的token呢?这些同样需要引起注意。

状态

传统方式是通过session来记录用户认证信息,可以理解为是一种无状态登录,而JWT则是一种无状态登录,那么有状态和无状态的区别是什么?在此之前需要了解http无状态以及最好知道cookie、session和token这三者之间的区别。

http无状态

http协议是无状态的,这里的无状态协议是指协议对于事务处理没有记忆能力。缺少状态,说明一旦数据交换完毕,客户端与服务器之间的连接就会关闭,若想再次进行数据交换则需建立新的连接,这就意味着服务器无法从连接上跟踪会话。

会话跟踪

会话,指用户登录网站后的一系列动作,如浏览商品、添加商品至购物车、购买商品等。会话(Session)跟踪是Web程序中常用的技术,用来跟踪用户的整个会话。常用的会话跟踪技术是Cookie和Session,其中Cookie通过在客户端记录信息以确定用户身份,而Session则在服务器端记录信息以确定用户身份,这么看来两者存放的位置不同。

由于http是一种无状态协议,因此服务器仅从网络连接上是无法识别用户身份。举个例子来说,假设小明看中了一件T恤并将其放入购物车中,由于网络原因它再次进入网站,想查看之前放在购物车中的T恤,但是由于http是无状态的,因此前后两个请求之前不存在任何联系,此时服务器不知道之前那个T恤是谁添加到购物车里面的,对于这种情况应该怎样解决呢?有人提出给客户端颁发通行证的办法,每个用户一个通行证,这样无论谁访问都必须携带自己通行证,通过这个操作,服务器就能借助于通行证来确认客户的信息,其实这就是Cookie的工作原理。


Cookie实际上是一小段文本信息。客户端请求服务器,如果服务器需要记录该用户状态,则可以使用Response向客户端浏览器颁发一个Cookie,然后客户端会把Cookie保存起来,这么看来Cookie存在于本地客户端中。当浏览器再请求该网站时,浏览器会请求的网址连同该Cookie一同提交给服务器,之后服务器检查该Cookie信息,并据此辨认用户状态,当然了服务器还可以根据需要来修改Cookie的内容。


Cookie分为会话Cookie和持久Cookie。若某个Cookie不设置过期时间,则该Cookie的生命周期为浏览器会话期间,当你关闭浏览器窗口,Cookie就失效,这种生命周期为浏览器会话期间的Cookie就是会话Cookie。通常来说,会话Cookie保存在内存中,不保存到磁盘中。

若某个Cookie设置了过期时间,那么浏览器会将该Cookie保存到硬盘上,用户关闭后再次打开浏览器,这些Cookie仍然有效直至超过设定的过期时间而失效。存储在硬盘上的Cookie可以在浏览器的不同进程间共享,这种Cookie称为持久Cookie。


请注意Cookie具有不可跨域名性,即当你访问百度时,是不会携带访问腾讯的Cookie。

session

Session是另一种记录客户状态的机制,不同于Cookie是保存在客户端中,Session保存在服务器上。当客户端访问服务器的时候,服务器会把客户端信息以某种形式记录在服务器上,这里的“某种形式”就是Session。当客户端再次访问服务器时,服务器只需要从该Session中查找客户信息即可。

每个用户访问服务器时,服务器都会为其分配一个session,那么服务器是如何标识用户身份呢?其实当用户与服务器建立连接时,服务器会自动为该用户分配一个sessionId,这个sessionId是服务器采用自己的规则生成,它保存在本地cookie里面。当用户再次发起请求时,这个sessionId会上传至服务器,服务器接收后会识别它,并返回相关的信息。

token

token是服务端生成的一串字符串,以作客户端进行请求的一个令牌,当第一次登录后,服务器生成一个token,便将此token返回给客户端,以后客户端就只需携带这个token就能请求数据,无需再次携带用户名和密码。

使用token的目的就是为了减轻服务器的压力,减少频繁的查询数据库,使服务器更加健壮。

使用基于token的身份验证方法,服务端不需要存储用户的登录记录。接下来学习基于token的身份验证流程,如下所示:
(1)客户端使用用户名和密码请求登录;
(2)服务端收到请求后,去验证用户名与密码;
(3)验证成功后,服务端会签发一个token,之后将这个token发送给客户端;
(4)客户端在收到token之后,将其存储起来,如放在Cookie或者Local Storage中;
(5)客户端每次向服务端请求资源的时候,它都需要携带服务端签发的token;
(6)服务端收到请求后,去验证客户端请求中携带的token,如果验证通过就向客户端返回请求的数据;
(7)App登录的时候发送加密的用户名和密码到服务器,服务器验证用户名和密码,如果成功,那么服务器会以某种方式如随机生成32位的字符串作为token,存储到服务器中,并返回token到App;
(8)之后App凡是需要验证的地方都需要携带该token,然后服务端验证token,验证成功则返回所需要的结果;失败则返回错误信息,并提示用户登录。

一般来说,我们都会在服务器上给token设置一个有效期,这样每次App请求的时候都需要验证token和有效期。

有状态

接下来学习什么是有状态服务,所谓的有状态服务是指服务端需要记录每次会话的客户信息,并据此识别客户端身份,验证通过后根据客户端身份进行请求处理,如Tomcat中的session就是此类的具体实现。当用户登录成功后,会将用户的信息保存在服务端的Session中,并将sessionId放在Cookie中用于返回给用户。等到用户下次访问的时候,用户会携带含有Cookie信息的请求到服务器,服务器会验证sessionId是否合法,当验证无误通过后会据此找到用户信息。

但是这种方式存在一些缺陷,当时间不断累计,服务端会保存大量的数据,这势必会增加服务端压力,还有由于服务器中保存的是用户的状态,因此对于集群化部署不太友好,极易造成用户信息访问断层现象。

基于有状态实现的登录流程:
1、用户输入用户名和密码,点击提交;
2、调用 login()命令, 后端程序会根据用户名密码生成sessionId并保存在数据库中;
3、用户登录之后,需要通过这个sessionId取出相关的基本信息。

无状态

说完了有状态,接下来学习无状态,单纯的介绍无状态似乎意义不大,因此结合目前流行的微服务架构来讲显得更有说服力。

我们知道微服务集群中每个服务对外都使用RESTful风格的接口,而RESTful风格有一个重要的规范就是“服务无状态性”。怎么理解这个“服务无状态性”呢?这里有两点需要注意:(1)服务端不保存客户端请求发起者的信息;(2)客户端的每次请求中必须包含描述发送者的信息,以便服务端通过这些信息唯一确定其身份。

那么问题来了,无状态相比于有状态有哪些优势呢?首先由于客户端发起的请求中已经包含了其发送者的描述信息,因此并不要求多次发送请求访问的必须是同一台服务器,这使得服务器集群化成为可能;其次,由于存在集群化部署,极大地降低了服务端的压力。

基于无状态实现的登录流程:
1、用户输入用户名和密码,点击提交;
2、调用 login()命令, 后端程序会将用户信息加密并编码成一个token,之后将其返回给客户端;
3、此后客户端每次发送请求都需要携带token,服务端对该token进行解密和验证,确认通过后即能获取用户基本信息。

小结

对于分布式项目来说,一般都是使用session,它最大的优点就是方便,但是最大的缺点就是必须依附于cookie而存在,像Android、小程序等是没有cookie,此时如果还想使用session就必须模拟cookie的存在,这样就有一种为了使用session而使用session的嫌疑,此时并不适合选择使用session,而应当选择类似于JWT的无状态登录,它只是依赖token,而token可以通过普通参数传递,也可以通过请求头传递,这么看来灵活性其实很高的。需要说明的是,如果开发者的前后端分离只是“网页+服务端”模式,此时使用类似于JWT的无状态登录就显得大材小用了,选择session则是较为合理的,因此到底选择哪种方式则需要结合具体情况来对待。

本文按照先易后难的顺序进行,因此本篇仅仅介绍基于session的认证,关于JWT的无状态登录,笔者会在后续文章中进行介绍。

前后端分离

接下来介绍前后端分离模式下的登录回调。我们知道在前后端分离模式下,前端是通过ajax请求向后端获取数据,而后端则是提供RESTful风格的API接口,两者通过JSON或者XML等数据格式来进行交互,通常是JSON格式。既然使用的是JSON格式数据,因此无论是登录成功或者失败,都不存在前面所说的页面跳转,永远只有一段JSON信息,前端可根据这段信息作出相应的动作,是登录成功还是失败,然后怎么操作都是前端自己决定。

登录成功

接下来介绍如何配置登录成功时的信息。在前后端不分离的模式下,对于用户登录成功的处理是通过successForwardUrldefaultSuccessUrl这两个方法来配置的,前面也说了它们都是用来配置跳转地址,并不适合此处前后端分离模式下的JSON信息传递。

在前面我们已经知道successForwardUrldefaultSuccessUrl都存在于AbstractAuthenticationFilterConfigurer类,其实所有关于用户验证的配置均在此类中,可以发现此类还有一个successHandler方法,查看一下该方法源码:

1
2
3
4
public final T successHandler(AuthenticationSuccessHandler successHandler) {
this.successHandler = successHandler;
return this.getSelf();
}

可以看到该方法不可以被重写,里面传入一个AuthenticationSuccessHandler对象,其实这个AuthenticationSuccessHandler对象是一个接口:

1
2
3
public interface AuthenticationSuccessHandler {
void onAuthenticationSuccess(HttpServletRequest var1, HttpServletResponse var2, Authentication var3) throws IOException, ServletException;
}

该接口中只有一个onAuthenticationSuccess方法,里面传入三个参数,分别是HttpServletRequestHttpServletResponseAuthentication。看到HttpServletRequestHttpServletResponse这两个参数就知道这个successHandler方法比之前的successForwardUrldefaultSuccessUrl方法更加强大,因为开发者可以利用HttpServletRequest做服务端跳转,HttpServletResponse做客户端跳转,当然想返回JOSN格式数据也是小菜一碟。Authentication参数用于保存登录成功的用户信息,后续可以从该参数中获取用户信息。

修改MyWebSecurityConfig类中configure(HttpSecurity http)方法中的代码为如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/goLogin")
.usernameParameter("name")
.passwordParameter("word")
.successHandler(new AuthenticationSuccessHandler() {
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
Object principal = authentication.getPrincipal();
httpServletResponse.setContentType("application/json;charset=utf-8");
PrintWriter out = httpServletResponse.getWriter();
out.write(new ObjectMapper().writeValueAsString(principal));
out.flush();
out.close();
}
})
.and()
.csrf().disable();
}

可以看到这里其实就是在successHandler()方法内部定义了一个匿名内部类AuthenticationSuccessHandler,并重写了该类中的onAuthenticationSuccess()方法,开发者完全可以使用lambda表达式来改写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/goLogin")
.usernameParameter("name")
.passwordParameter("word")
.successHandler((httpServletRequest,httpServletResponse,authentication) ->{
Object principal = authentication.getPrincipal();
httpServletResponse.setContentType("application/json;charset=utf-8");
PrintWriter out = httpServletResponse.getWriter();
out.write(new ObjectMapper().writeValueAsString(principal));
out.flush();
out.close(); }
)
.permitAll()
.and()
.csrf().disable();
}

然后启动项目,打开Postman等测试工具,新建一个post请求,相应的请求地址为http://localhost:8080/goLogin,则其填入的信息如下所示,请注意用户名和密码参数此时都变成了name和word:

可以看到确实已经登录成功了,只不过用户密码被擦除,不再显示而已,关于这一点会在后续《详解登录流程》一文中进行介绍。

登录失败

在前端后端分离模式下处理成功逻辑时,系统提供了一个successHandler方法,那么在登录失败的情况下是否也提供了一个对应的Handler呢?当然,对于失败登录而言,AbstractAuthenticationFilterConfigurer类也提供了failureHandler方法,用于处理登录失败的逻辑。查看一下该failureHandler方法的源码:

1
2
3
4
5
public final T failureHandler(AuthenticationFailureHandler authenticationFailureHandler) {
this.failureUrl = null;
this.failureHandler = authenticationFailureHandler;
return this.getSelf();
}

可以看到该方法不可以被重写,里面传入一个AuthenticationFailureHandler对象,其实这个AuthenticationFailureHandler对象是一个接口:

1
2
3
public interface AuthenticationFailureHandler {
void onAuthenticationFailure(HttpServletRequest var1, HttpServletResponse var2, AuthenticationException var3) throws IOException, ServletException;
}

该接口中同样只有一个onAuthenticationFailure方法,里面也是传入三个参数,分别是HttpServletRequestHttpServletResponseAuthenticationException。前2个参数前面已经提到过它的用法,而第三个参数AuthenticationException中则保存了登录失败的原因,开发者可以从中获取登录失败的原因,并通过JSON格式的数据传递给前端。

修改MyWebSecurityConfig类中configure(HttpSecurity http)方法中的代码为如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/goLogin")
.usernameParameter("name")
.passwordParameter("word")
.successHandler(new AuthenticationSuccessHandler() {
@Override
public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
Object principal = authentication.getPrincipal();
httpServletResponse.setContentType("application/json;charset=utf-8");
PrintWriter out = httpServletResponse.getWriter();
out.write(new ObjectMapper().writeValueAsString(principal));
out.flush();
out.close();
}
})
.failureHandler(new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(httpServletRequest,httpServletResponse,e) throws IOException, ServletException {
httpServletResponse.setContentType("application/json;charset=utf-8");
PrintWriter out = httpServletResponse.getWriter();
out.write(e.getMessage());
out.flush();
out.close();
}
})
.permitAll()
.and()
.csrf().disable();
}

同样上述代码依旧可以使用lambda表达式进行修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/goLogin")
.usernameParameter("name")
.passwordParameter("word")
.successHandler((httpServletRequest, httpServletResponse,authentication) ->{
Object principal = authentication.getPrincipal();
httpServletResponse.setContentType("application/json;charset=utf-8");
PrintWriter out = httpServletResponse.getWriter();
out.write(new ObjectMapper().writeValueAsString(principal));
out.flush();
out.close(); }
)
.failureHandler((httpServletRequest,httpServletResponse,e)-> {
httpServletResponse.setContentType("application/json;charset=utf-8");
PrintWriter out = httpServletResponse.getWriter();
out.write(e.getMessage());
out.flush();
out.close();
})
.permitAll()
.and()
.csrf().disable();
}

然后启动项目,打开Postman等测试工具,新建一个post请求,相应的请求地址为http://localhost:8080/goLogin,则其填入的信息如下所示:

可以看到由于此处输入错误的密码,导致用户不能成功登录,最后返回的是Bad credentials。很明显这个提示信息不够友好,因此开发者完全可以细化错误的粒度,failureHandler方法内的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
.failureHandler((httpServletRequest,httpServletResponse,e)-> {
httpServletResponse.setContentType("application/json;charset=utf-8");
PrintWriter out = httpServletResponse.getWriter();
httpServletResponse.setStatus(401);
Map<String, Object> map = new HashMap<>();
map.put("status", 401);
if (e instanceof LockedException) {
map.put("msg", "账户被锁定,登录失败!");
} else if (e instanceof BadCredentialsException) {
map.put("msg", "账户名或密码输入错误,登录失败!");
} else if (e instanceof DisabledException) {
map.put("msg", "账户被禁用,登录失败!");
} else if (e instanceof AccountExpiredException) {
map.put("msg", "账户已过期,登录失败!");
} else {
map.put("msg", "登录失败!");
}
ObjectMapper objectMapper = new ObjectMapper();
out.write(objectMapper.writeValueAsString(map));
out.flush();
out.close();
})
.permitAll()
.and()
.csrf().disable();
}

然后重新启动项目,使用刚才的Postman请求去提交,可以发现确实出现由于密码或者账户输错的提示信息:

需要说明的是尽管我们知道此处出错的原因是由于密码出错,但是出于安全考虑一般只会给一个模糊提示,即用“账户名称或者密码输出错误”这种提示,而不说使用诸如“密码输入错误”或者“账户输入错误”等精确提示。

需要说明的是在SpringSecurity中,用户名查找失败对应的异常为UsernameNotFoundException,而密码匹配失败对应的异常为BadCredentialsException,但是当我们在登录失败的回调中是看不到UsernameNotFoundException异常的,无论是用户名还是密码输入错误,最后抛出的异常都是BadCredentialsException,这一点需要特别注意。

那么你可能就要问了,为什么不显示UsernameNotFoundException异常呢?这就涉及到登录过程中较为关键的一个步骤:去加载用户数据,关于SpringSecurity整个登录流程的分析,笔者将在下一篇《详解登录流程》一文中进行介绍,此处就暂且跳过该内容。

未认证处理

现在我们来考虑用户未认证就访问数据的情况,实际的生活经验告诉我们,当你去淘宝购买东西时,如果想将喜欢的商品添加到购物车中,那么系统就会直接跳转到登录页面,在SpringSecurity中默认的行为也是这样,直接跳转到登录页面。

但是开发者似乎忘记了此处讨论的都是前后端分离模式下的系统开发,因此当用户没有登录时,后端不应该给用户返回一个登录页面,而是返回用户一段需要登录的提示信息,前端收到提示信息后,再自行决定是否进行页面跳转。

SpringSecurity提供了AuthenticationEntryPoint这个接口来实现上述功能,这个接口中只有一个commence方法:

1
2
3
public interface AuthenticationEntryPoint {
void commence(HttpServletRequest var1, HttpServletResponse var2, AuthenticationException var3) throws IOException, ServletException;
}

看参数就知道这个commence方法的作用,但是并没有具体的实现逻辑,因此需要查看这个AuthenticationEntryPoint接口的实现类LoginUrlAuthenticationEntryPoint,这个类就对commence方法进行了具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
String redirectUrl = null;
if (this.useForward) {
if (this.forceHttps && "http".equals(request.getScheme())) {
redirectUrl = this.buildHttpsRedirectUrlForRequest(request);
}

if (redirectUrl == null) {
String loginForm = this.determineUrlToUseForThisRequest(request, response, authException);
if (logger.isDebugEnabled()) {
logger.debug("Server side forward to: " + loginForm);
}

RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);
dispatcher.forward(request, response);
return;
}
} else {
redirectUrl = this.buildRedirectUrlToLoginPage(request, response, authException);
}

this.redirectStrategy.sendRedirect(request, response, redirectUrl);
}

可以看到这个方法用来判断是重定向(redirect)还是转发(forward),而这个useForward默认值为false,因此默认使用的是重定向(redirect)。

如果开发者想要实现之前既定的目标,就可以通过重写这个AuthenticationEntryPoint#commence方法,这里采用lambda方式书写代码。

修改MyWebSecurityConfig类中configure(HttpSecurity http)方法中的代码,在csrf验证后面添加如下代码:

1
2
3
4
5
6
7
8
.csrf().disable().exceptionHandling()
.authenticationEntryPoint((httpServletRequest,httpServletResponse,authException)->{
httpServletResponse.setContentType("application/json;charset=utf-8");
PrintWriter out = httpServletResponse.getWriter();
out.write("您尚未登录,请先登录!");
out.flush();
out.close();
});

可以看到在SpringSecurity的配置中添加了自定义的AuthenticationEntryPoint#commence方法,并在该方法内定义了需要返回的JSON信息,这样当用户直接去访问一个需要认证的页面,就不会发生页面跳转(重定向),后端返回给前端一段JSON提示信息,用户收到信息后,自主决定是否进行登录操作。

之后启动项目,打开Postman等测试工具,新建一个get请求,相应的请求地址为http://localhost:8080/book,则其填入的信息如下所示:

可以看到页面确实出现了JSON格式的提示信息。

注销登录

上一篇介绍的注销登录是基于前后端不分离情况下的,此时用户注销登录之后,系统会自动跳转到登录页面,显然这种情况方式不适合本篇学习的前后端分离模式。在前后端分离模式下,用户注销成功后只需返回JSON信息。

我们知道注销登录相关的配置信息都在LogoutConfigurer类中,因此查看一下该类源码,可以发现其中有一个logoutSuccessHandler方法,该方法传入一个LogoutSuccessHandler对象,而LogoutSuccessHandler是一个接口,里面只有一个onLogoutSuccess方法:

1
2
3
public interface LogoutSuccessHandler {
void onLogoutSuccess(HttpServletRequest var1, HttpServletResponse var2, Authentication var3) throws IOException, ServletException;
}

可以发现这个和未认证处理方式非常相似,因此可以借鉴上述操作。修改MyWebSecurityConfig类中configure(HttpSecurity http)方法中的代码,在csrf验证后面添加如下代码:

1
2
3
4
5
6
7
8
9
10
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessHandler((httpServletRequest,httpServletResponse,authentication)->{
httpServletResponse.setContentType("application/json;charset=utf-8");
PrintWriter out = httpServletResponse.getWriter();
out.write("注销登录成功!");
out.flush();
out.close();
}).and();

可以看到在SpringSecurity的配置中添加了自定义的LogoutSuccessHandler#onLogoutSuccess方法,并在该方法内定义了需要返回的JSON信息,这样当用户直接注销登录后,后端返回给前端一段JSON提示就完成注销。

之后启动项目,打开Postman等测试工具,新建一个post请求,相应的请求地址为http://localhost:8080/goLogin,则其填入的信息如下所示:

登录成功后,接下来再新建一个get请求,相应的请求地址为http://localhost:8080/logout,则其填入的信息如下所示:

可以看到此时用户注销登录后,后端会返回一段JSON提示信息,这样就完成了注销登录逻辑。

ok,那么本篇关于前后端分离模式下的回调、未认证处理和注销登录的学习就到此为止,下一篇将对登录流程进行详细学习。

参考文章: