可扩展的登录系统设计实现
By: Date: 2017年4月11日 Categories: 程序 标签:

看了一个可扩展用户登录的设计,确实想的比我要全面。看似登录一个小的功能,要考虑的东西还是很多的,安全性上,可扩展性,代码的风格上,作者就是从这些角度来考虑,具体可以看看下面参考资料中的设计,考虑了本地账户登录,OAuth2协议登录等。我看过之后按照设计实现了下,当然大体上只实现了本地用户的登录,第三方登录的就没做了。总的来说,设计思想还是很受启发的。当然你可以先看参考资料中的思想,回过头来再看实现就更简单了。

本地数据库设计

create table bp_user
(
 `user_id` int auto_increment comment '登录ID', 
 `state` int  comment '状态', 
 `emp_id` int  comment '员工ID', 
 `update_user` int  comment '更新人', 
 `update_time` datetime  comment '更新时间', 
 `delete_flag` int  comment '删除标志', 
  primary key (user_id) 
)AUTO_INCREMENT=1000;
alter table bp_user comment '用户';
 
create table bp_auth_local
(
 `user_id` int  comment '登录ID', 
 `username` varchar(64) not null comment '姓名', 
 `password` varchar(32) not null comment '密码', 
 `update_user` int  comment '更新人', 
 `update_time` datetime  comment '更新时间', 
 `delete_flag` int  comment '删除标志', 
  primary key (user_id) 
);
alter table bp_auth_local comment '用户本地验证';
注:如果是第三方登录OAuth,那我们类似的根据实际情况增加第二张表就可以了,如果区分QQ微信,微博,可以增加一个字段来区分。再或者其他方式的登录,只需要相应的增加第二张表即可,达到登录方式存储的扩展,并且使得用户信息与验证信息单独分开存储,避免信息的泄露。

验证接口及实现

为了能兼容多种登录方式,首先我们需要为所有的验证方式定义一个接口Authenticator

public interface Authenticator {
	LoginUser authenticate(HttpServletRequest request, HttpServletResponse response) throws AuthenticateException;
}

现在要实现本地用户登录,那我们需要只需要实现这个接口LocalCookieAuthenticator

public class LocalCookieAuthenticator implements Authenticator {
     
    @Autowired
    IAuthLocalService authLocalService;
     
    /**
     * 验证
     */
    public LoginUser authenticate(HttpServletRequest request, HttpServletResponse response) {
        String reqCookie =  CookieHelper.getCookieByName(request, AuthConstant.LOGIN_USER_COOKIE_KEY);
        if (reqCookie == null) {
            return null;
        }
         
        ServletContext sc = request.getSession().getServletContext();
        XmlWebApplicationContext cxt = (XmlWebApplicationContext)WebApplicationContextUtils.getWebApplicationContext(sc);
        if(cxt != null && cxt.getBean("authLocalService") != null && authLocalService == null){
            authLocalService = (IAuthLocalService) cxt.getBean("authLocalService");
        }
         
        LoginUser user= getUserByCookie(reqCookie);
        return user;
    }
     
    /**
     * 检查cookie
     * @param tocken
     * @return
     */
    private LoginUser getUserByCookie(String tocken){
        if(!StringUtils.isEmpty(tocken)){
            return authLocalService.checkTockenValid(tocken);
        }
        return null;
    }
}


当然,Basic认证的Authorization Header,我们需要一个BasicAuthenticator,对于用API Token认证的方式,同样编写一个APIAuthenticator。这样做,每增加一种验证方式,我们仅需增加类似上面的相应的具体实现类即可
实际上这里有一个验证Cookie的地方,就要考虑到安全性,既不能泄露泄露作为注册账号的邮箱等信息,也不能被轻易的破解或者伪造。那如何验证以及生成Cookie,我们将实现放到后面在来做。

过滤器Filter实现

接下来我们需要实现一个Filter来过滤所有的请求,AuthenticationFilter实现如下:

public class AuthenticationFilter implements Filter {
    // 忽略的过滤地址
    private String excludePages;
    private String[] excludePagesArray;
    // 所有的Authenticator都在这里
    Authenticator[] authenticators = initAuthenticators();
 
    // 每个页面都会执行
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
        // 链式认证获得User:
        HttpServletRequest httpRequest = (HttpServletRequest)request;
        HttpServletResponse httpResponse = (HttpServletResponse)response;
         
        LoginUser user=null ;
        if(!excludeRequest(httpRequest)){
            user = tryGetAuthenticatedUser(httpRequest, httpResponse);
            if(user==null){
                try {
                    String url=httpRequest.getRequestURI();
                    //判断获取的路径不为空且不是访问登录页面或执行登录操作时跳转     
                    if(!StringUtils.isEmpty(url) && ( url.indexOf("Login")<0 && url.indexOf("login")<0 )) {
                        httpResponse.sendRedirect(httpRequest.getContextPath() + "/login.jsp");
                        return;
                    }
                }
                catch(Exception e){
                }
            }else{
            }
        }
        // 把User绑定到UserContext中:
        try (UserContext ctx = new UserContext(user)) {
            chain.doFilter(request, response);
        }catch(Exception e){
             
        }
    }
     
    private LoginUser tryGetAuthenticatedUser(HttpServletRequest request, HttpServletResponse response){
         LoginUser user = null;
         try{
             for (Authenticator auth : this.authenticators) {
                 user = auth.authenticate(request,response);
                 if (user != null) {
                     break;
                 }
             }
         }catch(Exception e){}
         return user;
    }
     
    /**
     * 忽略的请求
     * @param request
     * @return
     */
    private boolean excludeRequest(HttpServletRequest request){
        for (String pageUrl : excludePagesArray) {//判断是否在过滤url之外
            if(request.getServletPath().equals(pageUrl)){     
                return true;
            }
        }
        return false;
    }
     
    /**
     * 创建登录方式
     * @return
     */
    private Authenticator&#91;&#93; initAuthenticators(){
        LocalCookieAuthenticator local = new LocalCookieAuthenticator();
        return new Authenticator&#91;&#93;{local};
    }
 
    /**
     * 初始化Filter配置
     */
    public void init(FilterConfig filterConfig) throws ServletException {
        excludePages = filterConfig.getInitParameter("excludePages");
        if (StringUtils.isNotEmpty(excludePages)) {
            excludePagesArray = excludePages.split(",");     
        }     
        return;
    }
 
    public void destroy() {
        // TODO Auto-generated method stub
    }
}

上面我只创建了本地用户验证的方式,如果我们创建了多种用户验证,则串联来验证,直到某一个验证通过为止。

绑定用户

那验证成功后的user对象我们放在了UserContext里,UserContext的实现如下:

public class UserContext implements AutoCloseable {
 
    static final ThreadLocal<LoginUser> current = new ThreadLocal<LoginUser>();
 
    public UserContext(LoginUser user) {
        current.set(user);
    }
 
    public static LoginUser getCurrentUser() {
        return current.get();
    }
     
    public void close() {
        current.remove();
    }
}

配置过滤器Filter

Filter实现了以后,接下来我们还需要将AuthenticationFilter配置到web.xml中以使过滤器生效。

<!-- 登录验证过滤器 -->
    <filter>
        <filter-name>authenticationFilter</filter-name>
        <filter-class>com.wte.configuration.filter.AuthenticationFilter</filter-class>
        <init-param>
            <param-name>excludePages</param-name>
            <param-value>/story/bp/user/login.do</param-value>
        </init-param>
    </filter>
    <filter-mapping>
        <filter-name>authenticationFilter</filter-name>
        <url-pattern>/story/*</url-pattern>
    </filter-mapping>

好了,这里我们基本上已经实现了验证的基本框架。总结下流程:首先Filter会过滤所有的请求,除去忽略的请求之外,对我们创建的验证方式顺序执行,直到验证通过为止,通过验证的User放到UserContext中,在我们业务代码里可以直接getCurrentUser()来获取登录用户。

Tocken验证及生成的实现

到这里我们好像还落下了上面提到的Cookie,来看看Cookie的生成的实现:

@Service
public class AuthLocalService extends AbstractService implements IAuthLocalService {

	@Resource
	protected AuthLocalDao authLocalDao;

	private String cookieExpireTime = PropertyUtils.getProperty(PropertyConstant.COOKIE_EXPIRE_TIME);

	/**
	 * 登录
	 */
	public OperationResult login(AuthLocalBean localAuthBean, HttpServletRequest request,
			HttpServletResponse response) {
		if (!StringUtils.isEmpty(localAuthBean.getUsername()) && !StringUtils.isEmpty(localAuthBean.getPassword())) {
			String encryptedPassword = HashHelper.MD5Encode(localAuthBean.getPassword(), null);
			AuthLocalBean entity = authLocalDao.findBy(localAuthBean.getUsername(), encryptedPassword);
			if (entity != null) {
				int minute = 20;
				if (!StringUtils.isEmpty(cookieExpireTime)) {
					Integer expireMinute = Integer.valueOf(cookieExpireTime);
					if (expireMinute != null) {
						minute = expireMinute.intValue();
					}
				}
				long expireTime = this.generateCookieExpireTime(minute);
				String tocken = this.generateCookieValue(entity, expireTime);

				Cookie cookie = new Cookie(AuthConstant.LOGIN_USER_COOKIE_KEY, tocken);
				cookie.setMaxAge(minute * 60);
				cookie.setPath("/");
				response.addCookie(cookie);
			} else {
				return new OperationResult(OperationResultType.Error);
			}
		}
		return new OperationResult(OperationResultType.Success);
	}

	public OperationResult logout(HttpServletRequest request, HttpServletResponse response) {
		Cookie cookie = new Cookie(AuthConstant.LOGIN_USER_COOKIE_KEY, "");
		cookie.setMaxAge(0);
		cookie.setPath("/");
		response.addCookie(cookie);
		return new OperationResult(OperationResultType.Success);
	}

	/**
	 * 验证Tocken
	 * 
	 * @param tocken
	 * @return
	 */
	public LoginUser checkTockenValid(String tocken) {
		String[] tockenParams = tocken.split(":");
		if (tockenParams.length == 4) {
			String validTime = tockenParams[2];
			if (checkValidDate(validTime)) {
				return this.checkTockenValid(tocken, tockenParams);
			}
		}
		return null;
	}

	/**
	 * 验证Tocken
	 */
	private LoginUser checkTockenValid(String tocken, String[] tockenParams) {
		AuthLocalBean entity = authLocalDao.find(tockenParams[0]);
		if (entity != null) {
			String tempTocken = this.generateCookieValue(entity, tockenParams[2], tockenParams[3]);
			if (tocken.equals(tempTocken)) {
				return new LoginUser(entity.getUserId(), entity.getUsername());
			}
		}
		return null;
	}

	/**
	 * 验证Tocken有效期
	 * @param strTime
	 * @return
	 */
	private boolean checkValidDate(String strTime){
		Long time= Long.valueOf(strTime);
		return DateUtils.getCurrentDate().getTime()< time;
	}

	/**
	 * 生成cookie有效时间
	 * 
	 * @param expireTime
	 *            有效小时数
	 * @return
	 */
	private long generateCookieExpireTime(int expireTime) {
		Date date = DateUtils.getCurrentDate();
		Calendar ca = Calendar.getInstance();
		ca.setTime(date);
		ca.add(Calendar.MINUTE, expireTime);
		return ca.getTime().getTime();
	}

	/**
	 * 生成tocken
	 * 
	 * @param user
	 * @param expireTime
	 * @return
	 */
	private String generateCookieValue(AuthLocalBean user, long expireTime) {
		int randomNum = (int) (Math.random() * (9999 - 1000 + 1)) + 1000;
		return this.generateCookieValue(user, String.valueOf(expireTime), String.valueOf(randomNum));
	}

	/**
	 * 生成tocken
	 * 
	 * @param userName
	 * @param encryptedPassword
	 * @param expireTime
	 * @return
	 */
	private String generateCookieValue(AuthLocalBean user, String expireTime, String randomNum) {
		String tempOriginStr = user.getUsername() + ":" + user.getPassword() + ":" + expireTime + ":" + randomNum;
		String reEncryptedPassword = HashHelper.MD5Encode(tempOriginStr, null);
		String cookieValue = user.getUserId() + ":" + reEncryptedPassword + ":" + expireTime + ":" + randomNum;
		return cookieValue;
	}
}

可以看generateCookieValue()里面,密码我们生成的步骤如下:
1. 组合字符串 user_id:password:expireTime:randomNum,设置变量名为tempOriginStr,其中password本身是数据库中经过HashHelper.MD5Encode()处理存储的。user_id使用无意义的值使得用户信息不会被利用。
2. 对tempOriginStr进行加密处理。
3. 重新进行组合再次生成字符串 user_id:tempOriginStr:expireTime:randomNum,作为我们响应的Cookie的Value值。

再来看看checkTockenValid()方法验证Cookie:
1. 对Cookie的Vlaue进行Split处理(),根据第三位即expireTime与当前时间来检查Tocken有效期;
2. 用第一位即user_id来查找用户信息。
3. 根据用户信息,调用generateCookieValue(),以及CookieValue的第三位即expireTime以及第四位randomNum来生成tocken,
4. 用生成的tocken与CookieValue的tocken进行比对,如果吻合则验证成功。否则失败。

现在我们的整个登录的验证就已经实现了,那至于验证中HashHelper.MD5Encode()的具体实现你可以随意的写了。其他的验证方式,我们也只是参考上面的代码相应增加验证的具体实现就可以了。如果有不明白的,可以去看参考资料,这里实现的思想都是从参考资料中的思想而来。

参考资料:

设计一个可扩展的用户登录系统 (1)
设计一个可扩展的用户登录系统 (2)
设计一个可扩展的用户登录系统 (3)

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注