可扩展的登录系统设计实现

看了一个可扩展用户登录的设计,确实想的比我要全面。看似登录一个小的功能,要考虑的东西还是很多的,安全性上,可扩展性,代码的风格上,作者就是从这些角度来考虑,具体可以看看下面参考资料中的设计,考虑了本地账户登录,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[] initAuthenticators(){
    	LocalCookieAuthenticator local = new LocalCookieAuthenticator();
    	return new Authenticator[]{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<AuthLocalBean,AuthLocalDao> 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<Integer>(OperationResultType.Error);
        	}
    	}
		return new OperationResult<Integer>(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<Integer>(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)

发表评论

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