开源项目学习-jeesite1.2.7-登录功能分析

写在前面

通过查看部分源码,发现登录功能是与Shiro深度结合的,但是这个Shiro我又没学,就很烦,所以先待我学习一波。

Shiro已学完,今天开始分析登录功能。

功能介绍

功能列表

官方文档

演示地址

账号system,密码jeesite.com

参考资料

shiro之深度解析FormAuthenticationFilter

shiro的具体认证流程

jeesite用户登录功能解析,这篇文章已经写的挺详细了,下面讲一下其它细节。

分析

LoginController中,login和loginFail两个函数就是处理form的函数,或者说正常情况下是由这两个函数来处理。但是仔细看这两个函数,并没有进行逻辑处理,只是简单的检查和跳转。这是因为shiro的登陆功能在controller之前加入了一个filter,用户校验信息被保存在了SystemAuthorizingRealm类中的Principal内部类中,在controller中获取之后进行进一步操作。

shiro认证部分

认证过程与学习资料中展示的例子有所不同,因为例子中直接通过subject进行了登录操作,而在jeestie项目中,用户名,密码等信息首先由formAuthenticatingFilter这个类处理的,并重写了父类中的一些方法。其父类执行了jubject.login(token),通过SystemAuthorizingRealm进行认证操作,返回的信息由formAuthenticatingFilter处理.

在formAuthenticationFilter中,重写的createToken函数会获取表单中的用户名称和密码,生成一个自定义的token。

protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) {
    String username = getUsername(request);
    String password = getPassword(request);
    if (password==null){
        password = "";
    }
    boolean rememberMe = isRememberMe(request);
    String host = StringUtils.getRemoteAddr((HttpServletRequest)request);
    String captcha = getCaptcha(request);
    boolean mobile = isMobileLogin(request);
    return new UsernamePasswordToken(username, password.toCharArray(), rememberMe, host, captcha, mobile);
}

然后调用父类方法executeLogin方法,首先调用了createToken方法获取token,然后调用subject.login(token)开始认证。

public abstract class AuthenticatingFilter extends AuthenticationFilter {
    public static final String PERMISSIVE = "permissive";

    public AuthenticatingFilter() {
    }

    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        AuthenticationToken token = this.createToken(request, response);
        if (token == null) {
            String msg = "createToken method implementation returned null. A valid non-null AuthenticationToken must be created in order to execute a login attempt.";
            throw new IllegalStateException(msg);
        } else {
            try {
                Subject subject = this.getSubject(request, response);
                subject.login(token);
                return this.onLoginSuccess(token, subject, request, response);
            } catch (AuthenticationException var5) {
                return this.onLoginFailure(token, var5, request, response);
            }
        }
    }
}

subject.login(token)会调用SystemAuthorizingRealm中的doGetAuthenticationInfo进行认证。

/**
 * 认证回调函数, 登录时调用
 */
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authcToken) {
   UsernamePasswordToken token = (UsernamePasswordToken) authcToken;
   
   int activeSessionSize = getSystemService().getSessionDao().getActiveSessions(false).size();
   if (logger.isDebugEnabled()){
      logger.debug("login submit, active session size: {}, username: {}", activeSessionSize, token.getUsername());
   }
   
   // 校验登录验证码
   if (LoginController.isValidateCodeLogin(token.getUsername(), false, false)){
      Session session = UserUtils.getSession();
      String code = (String)session.getAttribute(ValidateCodeServlet.VALIDATE_CODE);
      if (token.getCaptcha() == null || !token.getCaptcha().toUpperCase().equals(code)){
         throw new AuthenticationException("msg:验证码错误, 请重试.");
      }
   }
   
   // 校验用户名密码
   User user = getSystemService().getUserByLoginName(token.getUsername());
   if (user != null) {
      if (Global.NO.equals(user.getLoginFlag())){
         throw new AuthenticationException("msg:该已帐号禁止登录.");
      }
      byte[] salt = Encodes.decodeHex(user.getPassword().substring(0,16));
      return new SimpleAuthenticationInfo(new Principal(user, token.isMobileLogin()), 
            user.getPassword().substring(16), ByteSource.Util.bytes(salt), getName());
   } else {
      return null;
   }
}

先进行了验证码校验,如果认证失败次数大于等于3,则需要输入验证码,首先判断是否认证失败大于等于3,如果是,则获取当前用户的Session,然后获取其中保存的验证码,最后进行验证码校验。验证码如何生成?

之后就是校验用户名密码,在SystemAutnorizingRealm中有systemService的实例,该实例中的userDao能取出数据库中的name和password。

回到formAuthenticationFilter,如果认证失败,则会抛出AuthenticationException异常,通过catch捕获该异常后,会调用onLoginFailure方法,这个方法在FormAuthenticationFilter中进行了重写。

/**
	 * 登录失败调用事件
	 */
@Override
protected boolean onLoginFailure(AuthenticationToken token,
                                 AuthenticationException e, ServletRequest request, ServletResponse response) {
    String className = e.getClass().getName(), message = "";
    if (IncorrectCredentialsException.class.getName().equals(className)
        || UnknownAccountException.class.getName().equals(className)){
        message = "用户或密码错误, 请重试.";
    }
    else if (e.getMessage() != null && StringUtils.startsWith(e.getMessage(), "msg:")){
        message = StringUtils.replace(e.getMessage(), "msg:", "");
    }
    else{
        message = "系统出现点问题,请稍后再试!";
        e.printStackTrace(); // 输出到控制台
    }
    request.setAttribute(getFailureKeyAttribute(), className);
    request.setAttribute(getMessageParam(), message);
    return true;
}

这个方法主要就是把抛出的异常以及对应的信息写回了request中。

所以整个登录逻辑为:如果任何地方未登录,则访问登录页面,提交时先通过formAuthenticationFilter过滤器,验证账号密码,然后交给LoginController处理。

认证成功流程

    /**
    * 管理登录
    */
   @RequestMapping(value = "${adminPath}/login", method = RequestMethod.GET)
   public String login(HttpServletRequest request, HttpServletResponse response, Model model) {
      Principal principal = UserUtils.getPrincipal();

//    // 默认页签模式
//    String tabmode = CookieUtils.getCookie(request, "tabmode");
//    if (tabmode == null){
//       CookieUtils.setCookie(response, "tabmode", "1");
//    }

      if (logger.isDebugEnabled()){
         logger.debug("login, active session size: {}", sessionDAO.getActiveSessions(false).size());
      }

      // 如果已登录,再次访问主页,则退出原账号。
      if (Global.TRUE.equals(Global.getConfig("notAllowRefreshIndex"))){
         CookieUtils.setCookie(response, "LOGINED", "false");
      }

      // 如果已经登录,则跳转到管理首页
      if(principal != null && !principal.isMobileLogin()){
         return "redirect:" + adminPath;
      }
//    String view;
//    view = "/WEB-INF/views/modules/sys/sysLogin.jsp";
//    view = "classpath:";
//    view += "jar:file:/D:/GitHub/jeesite/src/main/webapp/WEB-INF/lib/jeesite.jar!";
//    view += "/"+getClass().getName().replaceAll("\\.", "/").replace(getClass().getSimpleName(), "")+"view/sysLogin";
//    view += ".jsp";
      return "modules/sys/sysLogin";
   }

认证成功逻辑,首先通过调用sessionDAO.getActiveSessions(false).size()获取了在线的用户的数量并打印。

if (logger.isDebugEnabled()){
    logger.debug("login, active session size: {}", sessionDAO.getActiveSessions(false).size());
}

下面这个不知道有啥用,待补。

// 如果已登录,再次访问主页,则退出原账号。
if (Global.TRUE.equals(Global.getConfig("notAllowRefreshIndex"))){
    CookieUtils.setCookie(response, "LOGINED", "false");
}

认证失败流程

/**
	 * 登录失败,真正登录的POST请求由Filter完成
	 */
@RequestMapping(value = "${adminPath}/login", method = RequestMethod.POST)
public String loginFail(HttpServletRequest request, HttpServletResponse response, Model model) {
    Principal principal = UserUtils.getPrincipal();

    // 如果已经登录,则跳转到管理首页
    if(principal != null){
        return "redirect:" + adminPath;
    }

    String username = WebUtils.getCleanParam(request, FormAuthenticationFilter.DEFAULT_USERNAME_PARAM);
    boolean rememberMe = WebUtils.isTrue(request, FormAuthenticationFilter.DEFAULT_REMEMBER_ME_PARAM);
    boolean mobile = WebUtils.isTrue(request, FormAuthenticationFilter.DEFAULT_MOBILE_PARAM);
    String exception = (String)request.getAttribute(FormAuthenticationFilter.DEFAULT_ERROR_KEY_ATTRIBUTE_NAME);
    String message = (String)request.getAttribute(FormAuthenticationFilter.DEFAULT_MESSAGE_PARAM);

    if (StringUtils.isBlank(message) || StringUtils.equals(message, "null")){
        message = "用户或密码错误, 请重试.";
    }

    model.addAttribute(FormAuthenticationFilter.DEFAULT_USERNAME_PARAM, username);
    model.addAttribute(FormAuthenticationFilter.DEFAULT_REMEMBER_ME_PARAM, rememberMe);
    model.addAttribute(FormAuthenticationFilter.DEFAULT_MOBILE_PARAM, mobile);
    model.addAttribute(FormAuthenticationFilter.DEFAULT_ERROR_KEY_ATTRIBUTE_NAME, exception);
    model.addAttribute(FormAuthenticationFilter.DEFAULT_MESSAGE_PARAM, message);

    if (logger.isDebugEnabled()){
        logger.debug("login fail, active session size: {}, message: {}, exception: {}",
                     sessionDAO.getActiveSessions(false).size(), message, exception);
    }

    // 非授权异常,登录失败,验证码加1。
    if (!UnauthorizedException.class.getName().equals(exception)){
        model.addAttribute("isValidateCodeLogin", isValidateCodeLogin(username, true, false));
    }

    // 验证失败清空验证码
    request.getSession().setAttribute(ValidateCodeServlet.VALIDATE_CODE, IdGen.uuid());


    // 如果是手机登录,则返回JSON字符串
    if (mobile){
        return renderString(response, model);
    }
    return "modules/sys/sysLogin";
}

认证失败逻辑,首先获取了principal,然后判断是否为空,既然认证失败了,为啥会不为空?待补

Principal principal = UserUtils.getPrincipal();

// 如果已经登录,则跳转到管理首页
if(principal != null){
   return "redirect:" + adminPath;
}

接下来是从request中获取信息,并进行了校验。当认证失败时,shiro会调用AuthenticatingFilter中的onLoginFailure方法,这也是为什么message中会有信息。如果message位空,则会写入用户或密码错误, 请重试

String username = WebUtils.getCleanParam(request, FormAuthenticationFilter.DEFAULT_USERNAME_PARAM);
boolean rememberMe = WebUtils.isTrue(request, FormAuthenticationFilter.DEFAULT_REMEMBER_ME_PARAM);
boolean mobile = WebUtils.isTrue(request, FormAuthenticationFilter.DEFAULT_MOBILE_PARAM);
String exception = (String)request.getAttribute(FormAuthenticationFilter.DEFAULT_ERROR_KEY_ATTRIBUTE_NAME);
String message = (String)request.getAttribute(FormAuthenticationFilter.DEFAULT_MESSAGE_PARAM);

if (StringUtils.isBlank(message) || StringUtils.equals(message, "null")){
   message = "用户或密码错误, 请重试.";
}

然后又将这些信息写回了model中

model.addAttribute(FormAuthenticationFilter.DEFAULT_USERNAME_PARAM, username);
model.addAttribute(FormAuthenticationFilter.DEFAULT_REMEMBER_ME_PARAM, rememberMe);
model.addAttribute(FormAuthenticationFilter.DEFAULT_MOBILE_PARAM, mobile);
model.addAttribute(FormAuthenticationFilter.DEFAULT_ERROR_KEY_ATTRIBUTE_NAME, exception);
model.addAttribute(FormAuthenticationFilter.DEFAULT_MESSAGE_PARAM, message);

接下来是对验证码的一些操作。首先判断认证异常信息是否是调用了,如果是,则调用isValidateCodeLogin(username, true, false)。之后清空seesion中的验证码。

// 非授权异常,登录失败,验证码加1。
if (!UnauthorizedException.class.getName().equals(exception)){
   model.addAttribute("isValidateCodeLogin", isValidateCodeLogin(username, true, false));
}

// 验证失败清空验证码
request.getSession().setAttribute(ValidateCodeServlet.VALIDATE_CODE, IdGen.uuid());

先来看看isValidateCodeLogin(username, true, false),这个方式在LoginController中进行了定义,这段代码主要的作用就是将用户认证失败的次数写入CacheUtils中,如果失败次数大于等于3,则返回true。返会的参数被置入了model。

/**
	 * 是否是验证码登录
	 * @param useruame 用户名
	 * @param isFail 计数加1
	 * @param clean 计数清零
	 * @return
     */
@SuppressWarnings("unchecked")
public static boolean isValidateCodeLogin(String useruame, boolean isFail, boolean clean){
    Map<String, Integer> loginFailMap = (Map<String, Integer>)CacheUtils.get("loginFailMap");
    if (loginFailMap==null){
        loginFailMap = Maps.newHashMap();
        CacheUtils.put("loginFailMap", loginFailMap);
    }
    Integer loginFailNum = loginFailMap.get(useruame);
    if (loginFailNum==null){
        loginFailNum = 0;
    }
    if (isFail){
        loginFailNum++;
        loginFailMap.put(useruame, loginFailNum);
    }
    if (clean){
        loginFailMap.remove(useruame);
    }
    return loginFailNum >= 3;
}

然后是将生成的IdGen.uuid()放入Session中,对应的变量名为ValidateCodeServlet.VALIDATE_CODE,这么做的作用就是给session中的验证码赋一个随机值,变相清空验证码

public class ValidateCodeServlet extends HttpServlet {
	
	public static final String VALIDATE_CODE = "validateCode";
    //...
}
/**
 * 封装各种生成唯一性ID算法的工具类.
 * @author ThinkGem
 * @version 2013-01-15
 */
@Service
@Lazy(false)
public class IdGen implements IdGenerator, SessionIdGenerator {

	private static SecureRandom random = new SecureRandom();
	
	/**
	 * 封装JDK自带的UUID, 通过Random数字生成, 中间无-分割.
	 */
	public static String uuid() {
		return UUID.randomUUID().toString().replaceAll("-", "");
	}
	//...
}

当认证失败次数大于等于3时,就会被要求输入验证码。

可以在登录页sysLogin.jsp中看到一下代码:

$(document).ready(function() {
   $("#loginForm").validate({
      rules: {
         validateCode: {remote: "${pageContext.request.contextPath}/servlet/validateCodeServlet"}
      },
      messages: {
         username: {required: "请填写用户名."},password: {required: "请填写密码."},
         validateCode: {remote: "验证码不正确.", required: "请填写验证码."}
      },
      errorLabelContainer: "#messageBox",
      errorPlacement: function(error, element) {
         error.appendTo($("#loginError").parent());
      } 
   });
});

感觉是请求了${pageContext.request.contextPath}/servlet/validateCodeServlet这个地址。通过全局搜索validateCodeServlet,在web.xml中发现了。

<!-- Validate code -->
<servlet>
   <servlet-name>ValidateCodeServlet</servlet-name>
   <servlet-class>com.thinkgem.jeesite.common.servlet.ValidateCodeServlet</servlet-class>
</servlet>
<servlet-mapping>
   <servlet-name>ValidateCodeServlet</servlet-name>
   <url-pattern>/servlet/validateCodeServlet</url-pattern>
</servlet-mapping>

从上图可以得出验证码就是通过servlet获取的。

public void doPost(HttpServletRequest request, HttpServletResponse response)
      throws ServletException, IOException {
   createImage(request,response);
}

private void createImage(HttpServletRequest request,
      HttpServletResponse response) throws IOException {
   
   response.setHeader("Pragma", "no-cache");
   response.setHeader("Cache-Control", "no-cache");
   response.setDateHeader("Expires", 0);
   response.setContentType("image/jpeg");
   
   /*
    * 得到参数高,宽,都为数字时,则使用设置高宽,否则使用默认值
    */
   String width = request.getParameter("width");
   String height = request.getParameter("height");
   if (StringUtils.isNumeric(width) && StringUtils.isNumeric(height)) {
      w = NumberUtils.toInt(width);
      h = NumberUtils.toInt(height);
   }
   
   BufferedImage image = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
   Graphics g = image.getGraphics();

   /*
    * 生成背景
    */
   createBackground(g);

   /*
    * 生成字符
    */
   String s = createCharacter(g);
   request.getSession().setAttribute(VALIDATE_CODE, s);

   g.dispose();
   OutputStream out = response.getOutputStream();
   ImageIO.write(image, "JPEG", out);
   out.close();

}

生成的验证存放到了Session里,以供认证器检查。

/*
 * 生成字符
 */
String s = createCharacter(g);
request.getSession().setAttribute(VALIDATE_CODE, s);

收获

代码千万条,安全第一条,从jeesite的登录流程可以看出,之前项目中写的登录就跟*一样,完全没有考虑安全问题。

配置文件

例子中的配置文件是写死在ShrioConfig中的,而jeesite则是将这些信息写入了配置文件中

<!-- Shiro权限过滤过滤器定义 -->
<bean name="shiroFilterChainDefinitions" class="java.lang.String">
    <constructor-arg>
        <value>
            /static/** = anon
            /userfiles/** = anon
            ${adminPath}/cas = cas
            ${adminPath}/login = authc
            ${adminPath}/logout = logout
            ${adminPath}/** = user
            /act/editor/** = user
            /ReportServer/** = user
        </value>
    </constructor-arg>
</bean>
<!-- 安全认证过滤器 -->
<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
    <property name="securityManager" ref="securityManager" />
    <property name="loginUrl" value="${adminPath}/login" />
    <property name="successUrl" value="${adminPath}?login" />
    <property name="filters">
        <map>
            <entry key="cas" value-ref="casFilter"/>
            <entry key="authc" value-ref="formAuthenticationFilter"/>
        </map>
    </property>
    <property name="filterChainDefinitions">
        <ref bean="shiroFilterChainDefinitions"/>
    </property>
</bean>

其中指定了${adminPath}/login的验证权限名为authc的过滤器。 authc对应的filter为formAuthenticationFilter。

可能用到的参考资料

SecurityUtils.getSubject()是怎么获取到当前用户信息的?

shiro SecurityUtils.getSubject()深度分析

posted @ 2022-06-07 17:00  EA2218764AB  阅读(549)  评论(0编辑  收藏  举报