大致的错误代码如下:
/** * 用户登陆,Shiro从Reaml中验证,返回JSON提示用户 * * @param request * @return * @throws Exception */ @RequestMapping("/login.do") @ResponseBody public Map<String, Object> login(HttpServletRequest request) throws Exception { Map<String, Object> resultMap = new LinkedHashMap<String, Object>(); //如果登陆失败从request中获取认证异常信息,shiroLoginFailure就是shiro异常类的全限定名 String exceptionClassName = (String) request.getAttribute("shiroLoginFailure"); //根据shiro返回的异常类路径判断,抛出指定异常信息 if (exceptionClassName != null) { if (UnknownAccountException.class.getName().equals(exceptionClassName)) { resultMap.put("message", "账号不存在"); } else if (IncorrectCredentialsException.class.getName().equals( exceptionClassName)) { resultMap.put("message", "用户名/密码错误"); } else if ("captchaCodeError".equals(exceptionClassName)) { resultMap.put("message", "验证码错误"); } else { throw new Exception();//最终在异常处理器生成未知错误 } } else { resultMap.put("message", "登陆成功"); //把用户信息写到session中 Subject subject = SecurityUtils.getSubject(); ActiveUser activeUser = (ActiveUser) subject.getPrincipal(); request.getSession().setAttribute("activeUser", activeUser); } return resultMap; }
$.ajax({ url: path + "/user/login.do", type: "post", async: false, data: $("#loginForm").serialize(), success: function (responseText) { console.log(responseText); if(responseText.message=="验证码错误"){ alert("验证码错误"); }else if(responseText.message=="账号不存在") { alert("账号不存在"); }else if(responseText.message=="用户名/密码错误") { alert("用户名/密码错误"); }else if(responseText.message=="登陆成功") { window.location.href = path + "/index.html"; }else { console.log(responseText); alert("未知错误"); } }, error: function () { alert("系统错误"); } })
在测试的时候就有非常怪异的想象:登陆的时候,有时可以返回正常JSON的信息、有的时候直接不调用ajax(后台打断点并没有进入后台),但是能够经过sucess方法返回一个页面的内容。
这就令我感到非常惊奇了,于是乎,我一直在搜索“为什么ajax不调用、success方法却回调了”、”sucess回调方法返回一个页面“、”ajax常见错误“。显然,我一直认为是ajax的问题,并没有怀疑Shiro的认证流程。在此方面花费我很多的时间,我怀疑过jquery的版本,还有其他方面的冲突…..
直到后来我就在想:为什么有的时候JSON返回一个页面的内容呢???此时我想起Shiro的认证流程了。如果认证不通过,Shiro默认返回给login.do处理,如果验证通过,shiro默认返回上一级请求的url。
也就是说:我在login.do中返回一个JSON是不合理的。因为如果没有认证的话,Shiro默认是需要返回登陆界面的,而我擅自修改成JSON了。于是就造成了奇怪的现象了。
那问题又来了,如果认证失败的话,为了做到更好的用户体验是需要实时地告诉用户哪里错了,而不是直接返回页面内容。用户不知道还会一脸懵逼。
因为login.do是专门处理异常的信息的,因此我们可以使用统一处理异常的方式去处理:
if (exceptionClassName != null) { if (UnknownAccountException.class.getName().equals(exceptionClassName)) { throw new UserException("账号不存在"); } else if (IncorrectCredentialsException.class.getName().equals( exceptionClassName)) { throw new UserException("密码错误了"); } else if ("captchaCodeError".equals(exceptionClassName)) { throw new UserException("验证码错误了"); } else { throw new Exception();//最终在异常处理器生成未知错误 } } return "redirect:/goURL/user/toLogin.do";
统一异常处理器:
/** * 统一异常处理类 */ public class SysException implements HandlerExceptionResolver { public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object o, Exception ex) { //输出异常 ex.printStackTrace(); String message = null; UserException userException = null; //如果ex是系统 自定义的异常,直接取出异常信息 if (ex instanceof UserException) { userException = (UserException) ex; } else { //针对非UserException异常,对这类重新构造成一个UserException,异常信息为“未知错误” userException = new UserException("未知错误"); } message = userException.getMessage(); request.setAttribute("message", message); try { //根据模版生成页面,重定向到页面中 Map<String, Object> map = new HashedMap(); map.put("title", "错误页面"); map.put("content", message); map.put("subject", "出错啦"); map.put("path", ReadPropertiesUtil.readProp("projectPath")); FreeMarkerUtils markerUtils = new FreeMarkerUtils(); markerUtils.ouputFile("promptPages.ftl", "promptPages.html", map); request.getRequestDispatcher("/promptPages.html").forward(request, response); } catch (Exception e) { e.printStackTrace(); } return new ModelAndView(); } }
上面已经解决了提示错误信息的问题了。可是我觉得不够好,因为错误信息跳转到别的页面了,用户需要重新回到登陆页面进行注册,这个也太麻烦了吧。ps(要是我登陆错误搞这么一个东西,我就认为这个是破网站…)。
于是乎,我就想在怎么实时把错误信息返回给登陆页面呢??ajax是否还能用呢??login方法是一定要返回一个页面的了。试了很多无用的方法,在网上也找不到相关的方法,当时搜索关键字”Shiro返回错误信息“…..。
此时,我就在想ajax和Shiro是否能结合起来…后来去搜索了一下”ajax和Shiro“才知道网上也有人遇到我这种情况。
参考资料:
http://blog.csdn.net/haiyang0735/article/details/51278387
https://my.oschina.net/WMSstudio/blog/162594
大致的思路就知道了:重写自定义表单过滤器的方法,判断是否为ajax请求来进行处理
期间找了很多相关的资料,每个人的实现都参差不齐。表单过滤器方法中的retrun true
和return false
也把我搞得一头雾水。最后,回归到搜索关键字“Shiro认证流程“,找到了一篇解决我问题的博文:
http://www.cnblogs.com/leechenxiang/p/7070229.html
经过上面的资料和阅读了Shiro相关的源码,我基本能知道shiro认证流程了,下面是我画的一张流程图:
根据流程可以判断在验证码失败时如果是ajax请求返回JSON数据。如果登陆失败,重写onLoginFailure方法,也判断是否为ajax请求。,
package zhongfucheng.shiro; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.subject.Subject; import org.apache.shiro.web.filter.authc.FormAuthenticationFilter; import zhongfucheng.entity.ActiveUser; import zhongfucheng.utils.ReadPropertiesUtil; import zhongfucheng.utils.WebUtils; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; /** * Created by ozc on 2017/10/27. */ /** * 自定义一个表单过滤器的目的就是认证流程由自己控制 */ public class UserFormAuthenticationFilter extends FormAuthenticationFilter { /** * 只要请求地址不是post请求和不是user/login(处理登陆的url),那么就返回登陆页面上 * * @param request * @param response * @return * @throws Exception */ @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletResponse httpServletResponse = (HttpServletResponse) response; //判断是否是登陆页面地址请求地址、如果不是那么重定向到controller的方法中 if (isLoginRequest(request, response)) { if (isLoginSubmission(request, response)) { //在提交给realm查询前,先判断验证码 if (WebUtils.validateCaptcha(httpRequest)) { return executeLogin(request, response); } else { if (isAjax(httpRequest)) { //这里要使用标准的JSON格式 WebUtils.printCNJSON("{\"message\":\"验证码错误\"}", httpServletResponse); return false; } else { // 放行 allow them to see the login page ;) httpRequest.setAttribute("shiroLoginFailure", "captchaCodeError"); return true; } } } else { // 放行 allow them to see the login page ;) return true; } } else { // TODO AJAX请求用户扩展。以后再补 if (isAjax(httpRequest)) { //httpServletResponse.sendError(ShiroFilterUtils.HTTP_STATUS_SESSION_EXPIRE); return false; } else { //返回配置的user/login.do,该方法会重定向到登陆页面地址,再次发送请求给本方法 saveRequestAndRedirectToLogin(request, response); } return false; } } /** * 认证成功,把用户认证信息保存在session中,判断是否为ajax请求 * @param token * @param subject * @param request * @param response * @return * @throws Exception */ @Override protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response) throws Exception { //在跳转前将数据保存到session中 HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletResponse httpServletResponse = (HttpServletResponse) response; ActiveUser activeUser = (ActiveUser) subject.getPrincipal(); WebUtils.setValue2Session(httpRequest, "activeUser", activeUser); //如果是ajax请求,那么我们手动跳转 //如果不是ajax请求,那么由Shiro帮我们跳转 if (isAjax(httpRequest)) { WebUtils.printCNJSON("{\"message\":\"登陆成功\"}", httpServletResponse); } else { //设置它跳转到首页路径,如果不设置它还会停留在登陆页面。 String indexPath = ReadPropertiesUtil.readProp("projectPath") + "/index.html"; org.apache.shiro.web.util.WebUtils.redirectToSavedRequest(request, response, indexPath); } return false; } /** * 认证失败、如果ajax请求则返回json数据 * 如果非ajax请求、则默认返回给login.do处理异常 * @param token * @param e * @param request * @param response * @return */ @Override protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) { HttpServletRequest httpServletRequest = (HttpServletRequest) request; HttpServletResponse httpServletResponse = (HttpServletResponse) response; // 不是ajax请求,就按照源码的方式去干(返回异常给controller,controller处理异常) if (!isAjax(httpServletRequest)) { setFailureAttribute(request, e); return true; } //是ajax请求,我们返回json给浏览器 String message = e.getClass().getSimpleName(); if ("IncorrectCredentialsException".equals(message)) { WebUtils.printCNJSON("{\"message\":\"密码错误\"}", httpServletResponse); } else if ("UnknownAccountException".equals(message)) { WebUtils.printCNJSON("{\"message\":\"账号不存在\"}", httpServletResponse); } else if ("captchaCodeError".equals(message)) { WebUtils.printCNJSON("{\"message\":\"验证码错误\"}", httpServletResponse); } else { WebUtils.printCNJSON("{\"message\":\"未知错误\"}", httpServletResponse); } return false; } /** * 判断ajax请求 * * @param request * @return */ boolean isAjax(HttpServletRequest request) { return (request.getHeader("X-Requested-With") != null && "XMLHttpRequest".equals(request.getHeader("X-Requested-With").toString())); } }
值得一提的是:手动返回JSON格式数据、要是标准的JSON格式。否则会出错
参考资料:
http://www.cnblogs.com/54td/p/6074456.html
login.do代码:
@RequestMapping("/login.do") public String login(HttpServletRequest request, HttpServletResponse response) throws Exception { /** * 如果在shiro配置文件中配置了authc的话,那么所点击的url就需要认证了才可以访问 * a:如果url是登陆请求地址(user/login.do),不是post请求的话,流程是不会去Realm中的。那么会返回到该方法中,也就是会返回登陆页面 * b:如果url是登陆页面地址,是post请求的话,那么去realm中对比,如果成功了那么跳转到在表单过滤器中配置的url中 * * c:如果url不是登陆页面地址,那么表单过滤器会重定向到此方法中,该方法返回登陆页面地址。并记住是哪个url被拦截住了 * d:用户填写完表单之后,会进入b环节,此时登陆成功后跳转的页面是c环节记住的url。 */ //如果登陆失败从request中获取认证异常信息,shiroLoginFailure就是shiro异常类的全限定名 String exceptionClassName = (String) request.getAttribute("shiroLoginFailure"); //根据shiro返回的异常类路径判断,抛出指定异常信息 if (exceptionClassName != null) { if (UnknownAccountException.class.getName().equals(exceptionClassName)) { throw new UserException("账号不存在"); } else if (IncorrectCredentialsException.class.getName().equals( exceptionClassName)) { throw new UserException("密码错误了"); } else if ("captchaCodeError".equals(exceptionClassName)) { throw new UserException("验证码错误了"); } else { throw new Exception();//最终在异常处理器生成未知错误 } } return "redirect:/goURL/user/toLogin.do"; }
忘记密码
对于忘记密码这个功能并不难,就跳转到忘记密码页面,让用户输入邮箱,去邮箱所给的超链接修改密码就行了。
记住我功能
本来我是想在登陆的时候勾选“记住我”,那么下次访问的时候,就可以直接访问了(因为我设置了一些链接是需要认证后才能访问的)。
原本是想使用Shiro的rememberMe这个功能的。发现它其实并没有为我们自动实现登陆!
具体的参考链接如下:
http://blog.csdn.net/nsrainbow/article/details/36945267
查了挺多资料的,发现Shiro的rememberMe这个功能并不好用,但我的系统是基于Shiro来进行开发的。
根据源码的文档说明:
public interface RememberMeAuthenticationToken extends AuthenticationToken { /** * Returns {@code true} if the submitting user wishes their identity (principal(s)) to be remembered * across sessions, {@code false} otherwise. * * @return {@code true} if the submitting user wishes their identity (principal(s)) to be remembered * across sessions, {@code false} otherwise. */ boolean isRememberMe(); }
只有在当前用户的session(Shiro的Session)存储了已认证的用户,那么才承认当前的用户是已登陆的。
我也想要实现rememberMe这个功能,那么只能手动去弄了。
我是通过拦截器来实现的:
public class LoginInterceptor implements HandlerInterceptor { @Autowired private UserService userService; public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o) throws Exception { Subject currentUser = SecurityUtils.getSubject(); //如果 isAuthenticated 为 false 证明不是登录过的,同时 isRememberd 为true 证明是没登陆直接通过记住我功能进来的 if (!currentUser.isAuthenticated() && currentUser.isRemembered()) { ActiveUser activeUser = (ActiveUser) SecurityUtils.getSubject().getPrincipal(); //获取session看看是不是空的 Session session = currentUser.getSession(); if (session.getAttribute("currentUser") == null) { User user = userService.validateUserExist(activeUser.getUsercEmail()); UsernamePasswordToken token = new UsernamePasswordToken(user.getUserEmail(), activeUser.getPassword(), currentUser.isRemembered()); //把当前用户放入session currentUser.login(token); session.setAttribute("currentUser",user); //设置会话的过期时间--ms,默认是30分钟,设置负数表示永不过期 session.setTimeout(-1000l); //这是httpSession、用户页面获取数据的。 httpServletRequest.getSession().setAttribute("activeUser", activeUser); } } return true; } public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception { } public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception { } }
那么就可以实现当用户即使关闭了浏览器之后,再次打开浏览器访问我们的页面还是登陆状态!
对登陆注册优化
最开始在页面使用路径都是自己手写:http://localhost:8080/zhongfucheng/
这样子的。这样并不通用。想要做得通用的话,那么就要使用变量了。而我又不想使用JSP页面。于是我的项目统一使用freemarker。
跳转到某个url的逻辑其实是一样的,没必要为每个跳转都写一个方法。于是我将其抽取出来:
/** * 通用的页面跳转 * * @param folder 模块 * @param file 具体文件 * @return */ @RequestMapping("/goURL/{folder}/{file}.do") public String goURL(@PathVariable("folder") String folder, @PathVariable("file") String file) { return "/" + folder + "/" + file + ".ftl"; }
对于首页而言我们可以使用监听器来对其进行初始化:
public class ProjectListener implements ServletContextListener { /** * 当项目启动的时候,我们就根据模版生成我们的index页面 * * @param servletContextEvent */ public void contextInitialized(ServletContextEvent servletContextEvent) { System.out.println(servletContextEvent.getServletContext().getRealPath("/")); try { FreeMarkerUtils markerUtils = new FreeMarkerUtils(); Map map = new HashMap(); map.put("path", ReadPropertiesUtil.readProp("projectPath")); markerUtils.ouputFile("index.ftl", "index.html", map); } catch (Exception e) { e.printStackTrace(); } } public void contextDestroyed(ServletContextEvent servletContextEvent) { } }
在JavaScript中可能也需要用到http://localhost:8080/zhongfucheng/
路径,我们可以使用JavaScript来获取项目的该路径,而不用写死:
<!--使用JS获取项目根路径--> <script> var path = ""; $(function () { var strFullPath = window.document.location.href; var strPath = window.document.location.pathname; var pos = strFullPath.indexOf(strPath); var prePath = strFullPath.substring(0, pos); var postPath = strPath.substring(0, strPath.substr(1).indexOf('/') + 1); path = prePath + postPath; }); </script>
比如我们在获取用户信息的时候就很方便了。
在代码会重复的情况下封装一些常用的Utils,或者使用别人写好的Utils