看了一个可扩展用户登录的设计,确实想的比我要全面。看似登录一个小的功能,要考虑的东西还是很多的,安全性上,可扩展性,代码的风格上,作者就是从这些角度来考虑,具体可以看看下面参考资料中的设计,考虑了本地账户登录,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 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()的具体实现你可以随意的写了。其他的验证方式,我们也只是参考上面的代码相应增加验证的具体实现就可以了。如果有不明白的,可以去看参考资料,这里实现的思想都是从参考资料中的思想而来。