spring security中如何弹出登录模态框(form login与ajax login并存)

问题

最近在项目中用到spring security3.1,本来想着只用form login一种方式,从而避免各种麻烦的配置,但是,后来发现,在实际使用过程,有些地方需要利用弹出登录模态框的方式,而这个需求加上session过期问题,开始变得有些复杂。例如以下场景:
在一个购买场景中,点击按钮“购买”,此时,如果用户已经登录,则完成用户下单并跳转到支付页面,如果用户没有登录或者session过期,那么,应该弹出一个登录框,在用户登录成功后,继续完成用户下单并跳转到支付页面。而登录失败时,提醒用户失败原因。如图
spring security中如何弹出登录模态框(form login与ajax login并存)_第1张图片

基本知识

先列出一些基本知识,这些也是在探索过程中学到的内容:
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 login,如果用form login,当账号密码错误时,必须跳转才能提醒用户错误原因。
  2. 如果ajax login在登录遇到后台RequestCache中保存的有内容(即之前访问了受保护的资源,例如点击了购买按钮),spring security在默认情况下会自动redirect到该请求,“可能”导致返回一个新的页面。
  3. 要保证用户登录成功后自动关闭模态框并直接继续完成下单,而不是让用户重新点击购买按钮。

试错

如果您没太多时间,请直接跳过这部分,看解决方案。
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. 将后端的ajax login与form login分开,ajax login方式登录时,后端返回json格式,例如”{\”status\”:true,\”redirect\”:\”http://xxx.xx/index.html\”}”,由前端获得对应的url,并使用top.location.href=”http://xxx.xx/index.html”进行跳转。而form login则直接redirect,由浏览器完成跳转。
  2. 配置spring security,使其在ajax方式访问受限资源,返回401错误,由前端ajax检测,以iframe的方式弹出登录模态框。
  3. 将点击按钮后,后端完成用户下单的事情,但不进行直接redirect,而是返回对应json,例如”{\”status\”:true,\”redirect\”:\”http://xxx.xx/paymoney\”}”,由前端利用window.location.href完成跳转。

这样的话,如果
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表单:

"loginForm" action="loginProcess" method="post"> "j_username" id="j_username" type="text" placeholder="Email"/> "j_password" id="j_password" type="password" placeholder="Password"/>

前端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代码?如果您有想法,还请不吝赐教。
另外,本教程代码大部分是从项目中摘出来了,为了方便展示,删掉了一些内容,并修改了部分细节,教程写的有点仓促,未测试是否可以正常运行,如果您在使用时遇到了问题,请帮忙指出,谢谢。

参考资料:

  1. Spring-security核心拦截器
  2. ajax与302响应

你可能感兴趣的:(项目开发)