访问控制对于一个web应用来说几乎是不可或缺的。当访问web应用当中的某些资源时,如果你这时还没有登录,那么就会被重定向到登录页面,只有在登录之后才会被允许访问。经常上网的朋友对这样一个场景一定不会陌生。那么,如何实现对部分访问受限的url进行保护呢?笔者在这里向大家介绍一种基于cookie的实现方法。
当用户登录的时候我们就在httpResponse中写入一个cookie,这个cookie就成为了一个标志。服务端每次收到http请求的时候就会先检查所请求的资源是否受到保护,如果是的话则会检查httpRequest中的cookie是否有效,只有检查通过了的才被允许访问,如果cookie无效则会被重定向到登录页面。对于那些不受访问限制的url则一律通过。
既然是基于Struts2的web应用,看到这样一个需求不禁让人想起了Struts2里的拦截器,而仔细研究Struts2之后也发现在它的发行包里正好也有一个cookie拦截器。
Struts2的cookie拦截器
<interceptor name="cookie" class="org.apache.struts2.interceptor.CookieInterceptor"/>
这个拦截器没有出现在Struts2的默认拦截器栈中,不过并不妨碍我们对其研究一番。首先这个拦截器有两个方法分别接受cookieName和cookieValue的参数。
/**
* Set the <code>cookiesName</code> which if matche will allow the cookie
* to be injected into action, could be comma-separated string.
*
* @param cookiesName
*/
public void setCookiesName(String cookiesName) {
if (cookiesName != null)
this.cookiesNameSet = TextParseUtil.commaDelimitedStringToSet(cookiesName);
}
/**
* Set the <code>cookiesValue</code> which if matched (together with matching
* cookiesName) will caused the cookie to be injected into action, could be
* comma-separated string.
*
* @param cookiesValue
*/
public void setCookiesValue(String cookiesValue) {
if (cookiesValue != null)
this.cookiesValueSet = TextParseUtil.commaDelimitedStringToSet(cookiesValue);
}
cookieName和cookieValue的参数可以在Struts2的配置文件中配置。
<action ... > <interceptor-ref name="cookie"> <param name="cookiesName">cookie1, cookie2</param> <param name="cookiesValue">cookie1value, cookie2value</param> </interceptor-ref> .... </action>
在这个拦截器的核心拦截方法中,它会从httpRequest中读取cookie,由populateCookieValueIntoStack方法将读取的cookie保存在一个map中。对于具体读取哪些cookie是由cookiesName和cookiesValue参数共同决定的,不过规则有些复杂和繁琐,这里就不具体展开讨论了。
public String intercept(ActionInvocation invocation) throws Exception {
if (LOG.isDebugEnabled())
LOG.debug("start interception");
final ValueStack stack = ActionContext.getContext().getValueStack();
HttpServletRequest request = ServletActionContext.getRequest();
// contains selected cookies
Map cookiesMap = new LinkedHashMap();
Cookie cookies[] = request.getCookies();
if (cookies != null) {
for (int a=0; a< cookies.length; a++) {
String name = cookies[a].getName();
String value = cookies[a].getValue();
if (cookiesNameSet.contains("*")) {
if (LOG.isDebugEnabled())
LOG.debug("contains cookie name [*] in configured cookies name set, cookie with name ["+name+"] with value ["+value+"] will be injected");
populateCookieValueIntoStack(name, value, cookiesMap, stack);
}
else if (cookiesNameSet.contains(cookies[a].getName())) {
populateCookieValueIntoStack(name, value, cookiesMap, stack);
}
}
}
injectIntoCookiesAwareAction(invocation.getAction(), cookiesMap);
return invocation.invoke();
}
最后将包含cookie的map注入到action中,当然,前提是这个action实现CookiesAware这个接口。
/**
* Hook that set the <code>cookiesMap</code> into action that implements
* {@link CookiesAware}.
*
* @param action
* @param cookiesMap
*/
protected void injectIntoCookiesAwareAction(Object action, Map cookiesMap) {
if (action instanceof CookiesAware) {
if (LOG.isDebugEnabled())
LOG.debug("action ["+action+"] implements CookiesAware, injecting cookies map ["+cookiesMap+"]");
((CookiesAware)action).setCookiesMap(cookiesMap);
}
}
研究完之后我们发现这个拦截器还是挺简单的,仅仅只是从httpRequest中读取cookie并注入到action中,里我们的需求还有很远的距离。一般的思路是在这个拦截器的基础上继续构造我们需要的东西。即在登录action中在登录成功之后将身份信息写入到cookie中,在拦截器中则验证cookie是否有效,如果无效则重定向到登录页面。这个实现方法看似可行,但仔细想想却存在着很多问题。
第一,登录action和拦截器变得过于臃肿。我们需要造登录action中不仅要加入写入cookie的代码,出于安全性的考虑,还要在写入cookie前对其进行加密处理,否则恶意用户可以伪造cookie带来安全隐患。拦截器中不仅要对cookie解密还要判断其是否有效。面向对象程序设计的原则告诉我们好的设计需要做到高聚合,也就是说一个类只做一件事情,显然用户登录属于业务层面的逻辑而处理cookie则属于比较底层的操作,将这二者放到一起肯定不是好的设计。
第二,降低了组件的可复用性。登录的逻辑是应用特有的,也就是说每一个应用都有它自己的登录逻辑,但是对cookie的操作或者说对于“只有在登录之后才会被允许访问某些受保护的资源,否则会被重定向到登录页面”这样的需求却是普遍存在的,既然如此我们应该可以把这样的需求抽象出来,做成可复用的组件,编入我们自己的工具库,以供不同的应用使用。
下面我们就来实现一种同样基于cookie的可复用的解决方案。
首先我们要设计好我们的ClientCookie类,这个ClientCookie类必须是对用户友好的,也就是说用户可以对其进行扩展但又不必关心底层的细节,我们可以通过提供一个对用户关心的ClientCookie与底层的httpCookie的映射来达到这个目的。同时我们把一些底层的数据,比如ip,时间戳等,login等封装在一个cookieMask中,从而把我们的ClientCookie类解放出来。下面是这个cookieMask类的代码,同时我们以一定的编码规则将这个cookieMask类映射到一个单独的httpCookie中。
public class ClientCookieMask implements Serializable{
private static final long serialVersionUID = 1L;
public static char SPLITER = '|';
private long lastTime= -1;
private long firstTime = -1;
private String loginId;
private String clientIp;
private boolean valid = false;;
/**
* mask 编码规则:
* loginId + '|' + IP + '|' + 'first time' +'|'+'last time'
*/
public ClientCookieMask(String mask){
String tmp[] = StringUtils.split(mask, SPLITER);
if(tmp!= null && tmp.length==4){
loginId = tmp[0];
clientIp = tmp[1];
try{
firstTime = Long.parseLong(tmp[2]);
}catch(NumberFormatException e){
firstTime = 0;
valid = false;
return;
}
try{
lastTime = Long.parseLong(tmp[3]);
}catch(NumberFormatException e){
lastTime = 0;
valid = false;
return;
}
valid = true;
}else{
valid = false;
}
}
public boolean isValid() {
return valid;
}
public long getLastTime() {
return lastTime;
}
public void setLastTime(long lastTime) {
this.lastTime = lastTime;
}
public long getFirstTime() {
return firstTime;
}
public void setFirstTime(long firstTime) {
this.firstTime = firstTime;
}
public String getClientIp() {
return clientIp;
}
public void setClientIp(String clientIp) {
this.clientIp = clientIp;
}
public String getLoginId() {
return loginId;
}
public void setLoginId(String loginId) {
this.loginId = loginId;
}
public String toString(){
StringBuffer buffer = new StringBuffer();
buffer.append(this.getLoginId());
buffer.append(ClientCookie.SPLITER).append(clientIp);
buffer.append(ClientCookie.SPLITER).append(firstTime);
buffer.append(ClientCookie.SPLITER).append(System.currentTimeMillis());
return buffer.toString();
}
}
在我们的ClientCookie类中,保存有一个对cookieMask的引用,同时我们还有一个map,每次调用这个cookie 的setter方法的时候,同时把set过来的值保存到这个map中,在代码中看不到这个过程,因为这是通过cglib实现的。cglib(Code Generation Library)是一个强大的、高性能、高质量的Code生成类库。它可以在运行期扩展Java类与实现Java接口。cglib封装了asm,可以在运行期动态生成新的class。
public abstract class ClientCookie {
private ClientCookieWriter cookieWriter;
public static char SPLITER = '|';
public static String DATE_FORMAT = "yyyy-MM-dd HH:mm:ss";
//单位分钟
private long maxLifeTime = -1;
//单位分钟
private long maxIdleTime = -1;
public long getMaxIdleTime() {
return maxIdleTime;
}
public void setMaxIdleTime(long maxIdleTime) {
this.maxIdleTime = maxIdleTime;
}
boolean inited = false;
//对ClientCookie使用setter方式的同时会把set的值保存到这个map中
Map<String,Object> settedMap;
boolean ipValidate = false;
String clientIp;
//对cookieMask的引用
private ClientCookieMask cookieMask;
/**
* mask 编码规则:
* loginId + '|' + IP + '|' + 'first time' +'|'+'last time'
*/
private String mask;
public long getMaxLifeTime() {
return maxLifeTime;
}
public void setMaxLifeTime(long idleTime) {
this.maxLifeTime = idleTime;
}
public String getClientIp() {
return clientIp;
}
public abstract String getLoginId();
//判断cookie是否有效
public boolean isValid(){
if(StringUtils.isEmpty(mask))return false;
ClientCookieMask cookieMask = getCookieMask();
if(cookieMask != null){
return cookieMask.isValid()
&& StringUtils.equals(getLoginId(), cookieMask.getLoginId())
&& validDuringTime(cookieMask.getFirstTime())
&& validIdleTime(cookieMask.getLastTime())
&& (ipValidate?StringUtils.equals(getClientIp(), cookieMask.getClientIp()):true);
}else{
return false;
}
}
//生成cookieMask
protected String generatMask(){
StringBuffer buffer = new StringBuffer();
buffer.append(this.getLoginId());
buffer.append(ClientCookie.SPLITER).append(clientIp);
buffer.append(ClientCookie.SPLITER).append(System.currentTimeMillis());
buffer.append(ClientCookie.SPLITER).append(System.currentTimeMillis());
return buffer.toString();
}
protected boolean validIdleTime(long lastTime){
if(maxIdleTime >0){
if((System.currentTimeMillis() - lastTime)<maxIdleTime * 60 * 1000){
return true;
}else{
return false;
}
}
return true;
}
/**
* lastTime
* @param lastTime
* @return
*/
protected boolean validDuringTime(long lastTime){
if(maxLifeTime >0){
if((System.currentTimeMillis() - lastTime)<maxLifeTime * 60 * 1000){
return true;
}else{
return false;
}
}else{
return true;
}
}
void setCookieWriter(ClientCookieWriter writer){
this.cookieWriter = writer;
}
public String getMask() {
return mask;
}
public ClientCookieMask getCookieMask() {
if(cookieMask == null){
cookieMask = new ClientCookieMask(mask);
}
return cookieMask;
}
public void setMask(String mask) {
this.mask = mask;
}
/**
* 持久化cookie(客户端或者cookieServer)
*/
public void save(){
setMask(generatMask());
cookieWriter.writeCookie(this);
settedMap.clear();
}
public void update(){
getCookieMask().setLastTime(System.currentTimeMillis());
setMask(this.getCookieMask().toString());
cookieWriter.writeCookie(this);
settedMap.clear();
}
public void clear(){
setMask(null);
cookieWriter.writeCookie(this);
settedMap.clear();
}
}
至此我们已经有了cookie类,但是还需建立客户友好的cookie与httpCookie之间的映射,我们把这个映射放在一个cookieMapping.xml的配置文件中。
<?xml version="1.0" encoding="utf-8"?> <!DOCTYPE cookieMapping SYSTEM "cookieMapping.dtd"> <cookieMapping cookieClass="com.meidusa.demo.web.Cookie" encryptKey="ei*736TR" loginUrl="http://www.meidusa.com:8080/demo/login.html" algorithm="DES" maxLifeTime="1440" maxIdleTime="-1"> <cookie cookieName="meidusa_cookie2" innerCookieName="loginId"> <property name="age">1000</property> <property name="domain">www.meidusa.com</property> <property name="writable">true</property> <property name="secure">true</property> <property name="path">/</property> </cookie> <cookie cookieName="meidusa_mask" innerCookieName="mask"> <property name="age">1000</property> <property name="domain">www.meidusa.com</property> <property name="writable">true</property> <property name="secure">true</property> <property name="path">/</property> </cookie> </cookieMapping>
dtd定义这里就不介绍了,cookieClass指的是用户自己定义的cookie类,这个类需要继承我们上面的ClientCookie,encryptKey是我们用来对cookie加密的key,这里的加密指的是在将cookie写入到httpCookie时的加密,loginUrl当用户没有登录访问受保护的资源时被重定向到的登录页面的url。algorithm是指加密算法的类型,maxLifeTime是指cookie的最长有效时间,maxIdleTime是指最长空闲时间,-1代表没有这个限制。
下面的cookie项代表了每一个cookie映射,cookieName代表的是底层的httpCookie的名字,innerCookieName指的是对客户友好的cookie名,这里我们可以看到我们把ClientCookie中的cookeMask拿出来映射成了一个httpCookie,又把loginId单独拿出来映射成了一个httpCookie。当然,用户可以根据自己的需要定制自己的ClientCookie类,并自己定义这个cookie的映射关系。
讲到这里,细心的读者可能已经发现,这个cookie的映射配置文件我们在上一篇文章中在讲自动配置的时候其实已经拿出来看过。是的,你猜对了,我们可以进一步的使用我们的autoconfig插件来管理这个配置文件,我们把部署特有的配置放到properties文件中,使用autoconfig通过模板生成我们真正需要的配置文件。
cookie.algorithm = DES cookie.domain = www.meidusa.com cookie.encryptKey = ei*736TR cookie.loginUrl = http://www.meidusa.com:8080/demo/login.xml
cookieMapping的模板文件
<?xml version="1.0" encoding="utf-8"?> <!DOCTYPE cookieMapping SYSTEM "cookieMapping.dtd"> <cookieMapping cookieClass="com.meidusa.demo.web.Cookie" encryptKey="${cookie_encryptKey}" loginUrl="${cookie_loginUrl}" algorithm="${cookie_algorithm}" maxLifeTime="1440" maxIdleTime="-1"> <cookie cookieName="meidusa_cookie2" innerCookieName="loginId"> <property name="age">1000</property> <property name="domain">${cookie_domain}</property> <property name="writable">true</property> <property name="secure">true</property> <property name="path">/</property> </cookie> <cookie cookieName="meidusa_mask" innerCookieName="mask"> <property name="age">1000</property> <property name="domain">${cookie_domain}</property> <property name="writable">true</property> <property name="secure">true</property> <property name="path">/</property> </cookie> </cookieMapping>
我们在servlet容器启动的时候通过我们自己的一个listenser来载入cookieMapping配置文件。
<context-param> <param-name>cookieMappingFile</param-name> <param-value>${project.home}/conf/cookieMapping.xml</param-value> </context-param> <listener> <listener-class> com.meidusa.toolkit.web.cookie.ClientCookieContextLoaderListener </listener-class> </listener>
这个listener的声明如下
public class ClientCookieContextLoaderListener implements ServletContextListener,ClientCookieWriter,ClientCookieReader,ClientNoLoginRedirector
ServletContextListener接口是servlet API的一部分,这里就不讨论了。除了ServletContextListener接口之外,这个listener还实现了ClientCookieWriter,ClientCookieReader,ClientNoLoginRedirector这三个接口。
public interface ClientCookieReader {
public ClientCookie readCookie(HttpServletRequest request) throws Exception;
}
public interface ClientCookieWriter {
public void writeCookie(ClientCookie cookie);
}
public interface ClientNoLoginRedirector {
public String getRedirectUrl(HttpServletRequest request);
}
从上面的声明中可以看到,这三个接口的功能分别是用来读取,写入cookie和重定向httpRequest。
也就是说我们的listener不仅负责装配配置文件,同时还负责对cookie 的底层操作。
从下面的代码中可以看到在容器初始化的时候这个listener负责装配cookieMapping的配置文件,同时也做一些初始化工作。具体的装配配置文件的代码这里就不贴了。
public void contextInitialized(ServletContextEvent sce) {
cookieMappingFile = sce.getServletContext().getInitParameter("cookieMappingFile");
this.setCookieMappingFile(cookieMappingFile);
init();
}
public synchronized void init(){
if(!inited){
cookieMappingFile = ConfigUtil.filter(cookieMappingFile);
loadConfig(cookieMappingFile);
ClientCookieFactory.getInstance().setReader(this);
ClientCookieFactory.getInstance().setWriter(this);
ClientCookieFactory.getInstance().setRedirector(this);
inited = true;
algorithm = SymmetricAlgorithm.valueOf(SymmetricAlgorithm.class, cookieConfig.getAlgorithm());
}
}
对底层cookie操作的代码,在读取cookie的时候,我们这里用到了cglib中的Enhancer和MethodInterceptor来拦截通过setter对cookie的访问,将set过来的值同步保存到一个map中。
public void writeCookie(ClientCookie cookie) {
if(cookie.settedMap != null){
for(Map.Entry<String, Object> entryProp : cookie.settedMap.entrySet()){
ClientCookieConfigEntry entry = cookieInnerNameMapping.get(entryProp.getKey());
if(entry!= null && entry.isWritable()){
javax.servlet.http.Cookie httpCookie = new javax.servlet.http.Cookie(entry.getCookieName(),"");
httpCookie.setDomain(entry.getDomain());
httpCookie.setPath(entry.getPath());
httpCookie.setVersion(1);
Object value;
try {
value = entryProp.getValue();
if(value == null){
httpCookie.setMaxAge(0);
}else{
httpCookie.setMaxAge(entry.getAge());
String cookieValue = value.toString();
if(cookieValue != null && entry.isSecure()){
cookieValue = encrypt(cookieValue);
}
httpCookie.setValue(cookieValue);
}
ServletActionContext.getResponse().addCookie(httpCookie);
} catch (Exception e) {
logger.error("write cookie error",e);
}
}
}
}
}
public ClientCookie readCookie(HttpServletRequest request) throws Exception {
ClientCookie cookie = (ClientCookie)Enhancer.create(Class.forName(cookieConfig.getCookieClass()),cookieSetterInterceptor);
cookie.setCookieWriter(this);
cookie.ipValidate = this.ipValidate;
cookie.clientIp = request.getRemoteAddr();
cookie.setMaxLifeTime(cookieConfig.getMaxLifeTime());
cookie.setMaxIdleTime(cookieConfig.getMaxIdleTime());
cookieMapping(cookie,request);
cookie.inited = true;
return cookie;
}
public String getRedirectUrl(HttpServletRequest request) {
String query = request.getQueryString();
String fullQuery = request.getRequestURL()+ (query == null?"":("?"+ query));
String encoded = fullQuery;
try {
encoded = URLEncoder.encode(fullQuery,"utf8");
} catch (UnsupportedEncodingException e) {
}
return cookieConfig.getLoginUrl()+"?redirect="+encoded;
}
在readCookie中的cookieSetterInterceptor是一个MethodInterceptor (注意,这里的MethodInterceptor并不是Struts2中的Interceptor,而是cglib中的MethodInterceptor)
class ClientCookieMothedInterceptor implements MethodInterceptor {
public Object intercept(Object object, Method method, Object[] args,
MethodProxy methodProxy) throws Throwable {
ClientCookie cookie = (ClientCookie)object;
Object result = methodProxy.invokeSuper(cookie,args);
if(cookie.inited){
if((method.getModifiers() & Modifier.PUBLIC)>0){
String name = method.getName();
if(name.startsWith("set") && name.length() > 3){
char[] fieldChars = name.toCharArray();
fieldChars[3] = Character.toLowerCase(fieldChars[3]);
String fieldName = new String(fieldChars,3,fieldChars.length-3);
if(cookie.settedMap == null){
cookie.settedMap = new HashMap<String ,Object>();
}
cookie.settedMap.put(fieldName, args[0]);
}
}
}
return result;
}
};
现在,我们已经有了自己的cookie类,cookie的映射,以及底层操作的api,最后我们再来看一下如何利用Struts2中的拦截器来完成最终的控制访问。在这里,通过继承,我们自己实现了一个拦截器。
public class ClientCookieInterceptor extends AbstractInterceptor {
private static final long serialVersionUID = 1L;
private static Logger logger = Logger.getLogger(ClientCookieInterceptor.class);
public void init() {
super.init();
}
@SuppressWarnings("unchecked")
@Override
public String intercept(ActionInvocation invocation) throws Exception {
ClientCookie cookie = null;
HttpServletRequest request = ServletActionContext.getRequest();
try{
cookie = ClientCookieFactory.getInstance().readCookie(request);
}catch(Exception e){
logger.error("mapping cookie error",e);
return redirect();
}
if(!(invocation.getAction() instanceof ClientCookieNotCare)){
if(!cookie.isValid()){
return redirect();
}
}
if(invocation.getAction() instanceof ClientCookieAware){
ClientCookieAware cookieAware = (ClientCookieAware)invocation.getAction();
cookieAware.setClientCookie(cookie);
}
request.setAttribute("clientCookie", cookie);
if(!(invocation.getAction() instanceof ClientCookieNotCare)){
if(cookie != null){
if(cookie.getCookieMask() != null){
cookie.update();
}
}
}
return invocation.invoke();
}
protected String redirect() throws IOException{
HttpServletRequest request = ServletActionContext.getRequest();
String redirectUrl = ClientCookieFactory.getInstance().getRedirectUrl(request);
ServletActionContext.getResponse().sendRedirect(redirectUrl);
return Action.NONE;
}
}
在这个拦截器中会检查,如果用户action没有实现ClientCookieNotCare接口并且cookie无效则会被重定向到登录页面;如果用户action实现了ClientCookieAware接口则会自动注入cookie以供用户使用。
ClientCookieNotCare和ClientCookieAware接口的定义
public interface ClientCookieAware<T extends ClientCookie> {
public void setClientCookie(T t);
}
public interface ClientCookieNotCare {
}
最后别忘了在struts.xml配置一下这个拦截器,并把它一并放入常用的拦截器栈中
<interceptor name="cookieInterceptor" class="com.meidusa.toolkit.web.cookie.ClientCookieInterceptor" />
<interceptor-stack name="cookieDefaultStack"> <interceptor-ref name="cookieInterceptor" /> <interceptor-ref name="defaultStack"></interceptor-ref> </interceptor-stack>
至此,我们应该已经成功实现了一种基于cookie的访问控制方法。