最近在项目中用到spring security3.1,本来想着只用form login一种方式,从而避免各种麻烦的配置,但是,后来发现,在实际使用过程,有些地方需要利用弹出登录模态框的方式,而这个需求加上session过期问题,开始变得有些复杂。例如以下场景:
在一个购买场景中,点击按钮“购买”,此时,如果用户已经登录,则完成用户下单并跳转到支付页面,如果用户没有登录或者session过期,那么,应该弹出一个登录框,在用户登录成功后,继续完成用户下单并跳转到支付页面。而登录失败时,提醒用户失败原因。如图
先列出一些基本知识,这些也是在探索过程中学到的内容:
1. AuthenticationEntryPoint,当用户请求一个受保护的资源时,如果用户未通过认证,则抛出异常,会被捕获,并自动调用AuthenticationEntryPoint的commence方法。最常用的是LoginUrlAuthenticationEntryPoint,会在访问受限时,自动跳转(redirect)到登录页面。
2. 在spring security中,有RequestCache,其主要作用是当用户访问一个受保护的资源时,如果尚未通过认证,则RequestCache中会自动保存用户准备访问的Request,并在用户通过认证后,恢复访问。例如上述,点击购买之后,如果用户未登录,则跳转到登录页面(注意,不是弹出一个模态框),在用户登录之后,会自动再访问购买的链接。
3. 在form-login配置中,可以通过always-use-default-target来控制是否绕过RequestCache,当设置为true时,则会保证在登录成功时,一定访问default-target-url,而不会去恢复原有的连接。
4. 在ajax请求时,如果服务器端进行redirect,即响应302 Found,而ajax的回调函数是无法获得这个状态码的。如果最终的redirect资源存在,则会返回200,如果不存在,则会返回404。另外,更难受的是,在返回200的时候,ajax中获得的data是redirect后的资源内容,即使资源内容已经拿到,甚至可以利用javascript将其显示在页面上,但是,在ajax中,却不能通过getResponseHeader拿到对应的url,即便在浏览器调试器中可以看到这个响应链接。(原因在于302将由浏览器直接响应并进行重定向,而不会经过javascript,也就是javascript根本没机会拿到302)
如果您没太多时间,请直接跳过这部分,看解决方案。
1. 直观的想法是,按照流程图,一点点来,在点击按钮时,先用ajax去和后台交互,判断该用户是否登录,主要排除假登录。第1个坑:无法在后台直接获得Principal。如果您要用ajax进行交互,那么,这个交互的链接,例如/isLogin必须是开放的并且不需要权限,而如果其不需要权限,在配置之后,访问/isLogin对应的方法时,principal并不会注入,获得的principal将始终是null,也就没法判断是否已经登录(后来才知道:如果用
代替
) 赋予权限,是可以在对应方法中获得principal对象的。)
2. 另外一个想法,通过后台的配置,把ajax login与form login区分开。在需要判断是否登录并弹出模态框的地方,将资源访问方式改为ajax。在session过期或者未登录时,拦截ajax方式访问受限资源,返回401错误,由ajax检测,弹出登录模态框。重写并配置ajax方式下的SavedRequestAwareAuthenticationSuccessHandler(用于管理登录成功后,如果RequestCache中有内容,则redirect),使用户用ajax登录成功后,后端并不直接redirect,而是将url返回,交给前端进行url跳转。相反,如果在访问受限资源时,已经登录了,那么,ajax会拿到对应的页面内容,通过javascript将其直接填充到浏览器中,并改写对应的url即可。第2个坑:即使拿到了内容,url拿不到,是的,没拿到, 也就不能改写用户地址栏,会导致用户刷新后,重新下单事件发生。参见基本知识4。
3. 第3个坑,在实施第2个想法时,原本没想到改写ajax方式下的SavedRequestAwareAuthenticationSuccessHandler,而是直接将ajax login方式中的always-use-default-target设置为true,绕过RequestCache,由default-target-url中指定的方法访问RequestCache中的内容,并通过json方式返回最初受限的url。实验之后才发现,default-target-url中指定的方法根本拿不到RequestCache中的内容,因为在该方法之前,对应的session值已经被清除。
这样的话,如果
1. 已登录用户:直接下单,将url返回前端ajax,前端ajax完成跳转到支付页面。
2. 未登录用户:下单时服务器返回401错误,弹出登录模态框,spring security中的RequestCache会记录准备访问“下单”的链接。如果用户输入的账号密码错误,ajax返回错误信息,并在firame中展示。如果用户输入的用户密码正确,则服务器会自动redirect到“下单”这个链接中,并由下单的链接返回对应的json串,由ajax login中的ajax完成跳转到支付页面。
简单明了。
securityConfig.xml
<beans:bean id="entryPoint" class="org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint">
<beans:constructor-arg>
<beans:map>
<beans:entry>
<beans:key>
<beans:bean class="com.security.AjaxRequestMatcher" />
beans:key>
<beans:bean class="com.security.Http401EntryPoint" />
beans:entry>
beans:map>
beans:constructor-arg>
<beans:property name="defaultEntryPoint">
<beans:bean class="org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint">
<beans:constructor-arg value="/user/login"/>
beans:bean>
beans:property>
beans:bean>
<http auto-config="true" use-expressions="true" entry-point-ref="entryPoint">
<intercept-url pattern="/buyProduction/**" access="hasRole('ROLE_USER')"/>
<form-login
login-processing-url="/loginProcess"
authentication-failure-url="/user/login/failed"
default-target-url="/user/login/succeed"/>
<logout invalidate-session="true"
logout-success-url="/index.html"
logout-url="/user/logout"/>
http>
com.security.AjaxRequestMatcher.java
package com.security;
import javax.servlet.http.HttpServletRequest;
import org.springframework.security.web.util.RequestMatcher;
public class AjaxRequestMatcher implements RequestMatcher{
@Override
public boolean matches(HttpServletRequest request) {
//判断是否访问为ajax
return "XMLHttpRequest".equals(request.getHeader("X-Requested-With"));
}
}
com.security.Http401EntryPoint
package com.security;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
public class Http401EntryPoint implements AuthenticationEntryPoint{
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
response.sendError(401);
}
}
form方式登录成功后的响应(RequestCache为空情况):
/**
* 账户登录成功时返回结果
* @throws IOException
*/
@RequestMapping(value="/user/login/succeed",method = {RequestMethod.GET,RequestMethod.HEAD})
public String loginSucceed(HttpServletRequest request,HttpServletResponse response){
return "redirect:/index.html";
}
ajax方式登录成功后的响应(RequestCache为空情况),注意我们这里使用headers = "x-requested-with=XMLHttpRequest",
来帮助区别于form login请求:
/**
* 使用ajax进行账户登录成功时返回结果
* @throws IOException
*/
@RequestMapping(value="/user/login/succeed", method = {RequestMethod.GET,RequestMethod.HEAD},
headers = "x-requested-with=XMLHttpRequest",
produces = "application/json; charset=utf-8")
@ResponseBody
public String loginSucceedByAjax(HttpServletRequest request,
HttpServletResponse response){
String path = request.getContextPath();
String basePath = request.getScheme()+"://"+request.getServerName()+":"+request.getServerPort()+path+"/";
String redirectUrl = basePath+"index.html";
return "{\"status\":true,\"redirect\":\""+redirectUrl+"\"}";
}
form方式登录失败后的响应
/**
* 账户登录失败时返回结果
* @throws IOException
*/
@RequestMapping(value="/user/login/failed", method = {RequestMethod.GET,RequestMethod.HEAD})
public String loginFailed(
HttpSession session,
RedirectAttributes redirectAttributes
) throws IOException{
Object err = session.getAttribute("SPRING_SECURITY_LAST_EXCEPTION");
if(err!=null){
if(err instanceof UsernameNotFoundException){
//添加某些错误信息,方便前端获取(这里使用了一些自定义的类,读者仅供参考)
redirectAttributes.addFlashAttribute("toast",new ToastMessage(ToastLevel.ERROR, "用户名不存在!"));
return "redirect:/user/login";
}else if(err instanceof BadCredentialsException){
redirectAttributes.addFlashAttribute("toast",new ToastMessage(ToastLevel.ERROR, "密码错误!"));
return "redirect:/user/login";
}else if(err instanceof LockedException){
redirectAttributes.addFlashAttribute("toast",new ToastMessage(ToastLevel.ERROR, "账户被锁定!"));
return "redirect:/user/login";
}else if(err instanceof DisabledException){
redirectAttributes.addFlashAttribute("toast",new ToastMessage(ToastLevel.ERROR,"您需要激活邮箱才能登录!"));
return "redirect:/user/login";
}else if(err instanceof CredentialException){
redirectAttributes.addFlashAttribute("toast",new ToastMessage(ToastLevel.ERROR, "验证过期,请重新登录验证!"));
return "redirect:/user/login";
}else{
log.error(((DisabledException) err).getMessage());
redirectAttributes.addFlashAttribute("toast",new ToastMessage(ToastLevel.ERROR, "系统错误,请稍后重试!"));
return "redirect:/user/login";
}
}else{
redirectAttributes.addFlashAttribute("toast",new ToastMessage(ToastLevel.ERROR, "系统错误,请稍后重试!"));
return "redirect:/user/login";
}
}
ajax方式登录失败后的响应:
/**
* 使用ajax方式登录失败时返回的结果
* @param session
* @param redirectAttributes
* @return
* @throws IOException
*/
@RequestMapping(value="/user/login/failed", method = {RequestMethod.GET,RequestMethod.HEAD},
headers = "x-requested-with=XMLHttpRequest",
produces = "application/json; charset=utf-8")
@ResponseBody
public String loginFailedByAjax(
HttpSession session,
RedirectAttributes redirectAttributes
) throws IOException{
Object err = session.getAttribute("SPRING_SECURITY_LAST_EXCEPTION");
if(err!=null){
if(err instanceof UsernameNotFoundException){
return "{\"status\":false,\"info\":\"用户名不存在!\"}";
}else if(err instanceof BadCredentialsException){
return "{\"status\":false,\"info\":\"密码错误!\"}";
}else if(err instanceof LockedException){
return "{\"status\":false,\"info\":\"账户被锁定!\"}";
}else if(err instanceof DisabledException){
return "{\"status\":false,\"info\":\"您需要'user/register/resend\\'>激活邮箱才能登录!\"}";
}else if(err instanceof CredentialException){
return "{\"status\":false,\"info\":\"验证过期,请重新登录验证!\"}";
}else{
log.error(((DisabledException) err).getMessage());
return "{\"status\":false,\"info\":\"系统错误,请稍后重试!\"}";
}
}else{
return "{\"status\":false,\"info\":\"系统错误,请稍后重试!\"}";
}
}
前端form表单:
前端ajax方式表单及代码
<form id="loginForm">
<input name="j_username" id="j_username" type="text" placeholder="Email"/>
<input name="j_password" id="j_password" type="password" size="40" placeholder="Password"/>
<button type="button" onclick="ajaxSubmit()">登录button>
<button type="reset">重置button>
form>
<script type="text/javascript">
function ajaxSubmit(){
$.ajax({
type:"post",
url:"loginProcess",
dataType:"json",
async:true,
data:$("#loginForm").serialize(),
success:function(data, textStatus, jqXHR){
if(data.status==true){
if(data.redirect){
top.location.href = data.redirect;
}else{
toastr.info(data.info);
}
}else{
toastr.error(data.info);
}
},
error:function (XMLHttpRequest, textStatus, errorThrown){
if(textStatus=="timeout"){
toastr.error("响应超时,请稍后重试");
}else{
toastr.error("登录异常,请稍后重试");
}
}
});
}
script>
商品购买页面:
<div class="modal fade" id="loginModal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header" style="border-bottom: 0px;">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×button>
div>
<div class="modal-body">
<iframe id="loginContent" name="loginContent" scrolling="no" frameborder="0" width="100%;" height="400px;">iframe>
div>
div>
div>
div>
<button type="button" onclick="buyProduction('abcdABCD')">购买button>
<script type="text/javascript">
function buyProduction(productionId){
$.ajax({
url: "/buyProduction/"+productionId,
type:'GET',
dataType:"json",
success:function(data, textStatus, jqXHR){
if(data.redirect)//访问支付链接
window.location.href = data.redirect;
},
error:function (XMLHttpRequest, textStatus, errorThrown) {
if(errorThrown=="Unauthorized"){//401错误
$("#loginContent").attr("src", "user/login?iframe=true");
$("#loginModal").modal("show");
}
}
});
}
script>
后端下单方法:
@RequestMapping(value = "/buyProduction/{productionId}")
@ResponseBody
public String buy(
@PathVariable String productionId
){
//获得用户信息,并下单过程
User usr = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
...
//返回对应的值
if(isSuccess){
return "{\"status\":true,\"redirect\":\"user/register\"}";
}else{
return "{\"status\":false,\"info\":\"下单失败\"}";
}
}
本来以为是一个非常小的需求,分分钟就搞定的事情,但是在实际过程中,却踩到了多个坑,浪费了N多青春,从开始轻视,到回来搜遍整个网络也没找到满意的方案,从希望变到绝望,代码从简短改成臃肿再删到简短,这几天我都干了什么?哦,只调了一个按钮,真的只调了一个按钮。好吧,我还是先想想怎么给老板汇报吧?一个按钮的故事?
这里解决了目前项目中的一个实际需要,点击购买,弹窗登录或者跳转。但是,如果是点击购买后,要求不跳转呢?例如,仅仅在本页上将购买按钮隐去,并换成一个购买成功的图片?即,如果能在访问受阻后,当登录成功后,继续执行那段为执行下去的javascript代码?如果您有想法,还请不吝赐教。
另外,本教程代码大部分是从项目中摘出来了,为了方便展示,删掉了一些内容,并修改了部分细节,教程写的有点仓促,未测试是否可以正常运行,如果您在使用时遇到了问题,请帮忙指出,谢谢。