1. 安全Ice程序的设计模式
使用Ice设计安全应用程序的方法有很多。适用于传统Web应用程序的大多数原则,可以转换为Ice应用程序(例如,基于会话的身份验证或基于令牌的身份验证)。与使用非持久连接的HTTP不同,Ice也支持安全的持久连接。
我们将在这篇文章中探讨一些构建安全Ice应用程序的常用方法。
1.1. 基于令牌的安全
基于令牌的安全在Web应用程序中非常常见:客户端向服务器发送凭证,服务器依次使用令牌进行回复。该令牌对于客户端是不透明的,它包含服务器用来检查客户端是否有权访问资源的信息。这种方法的优点是它不需要服务器维护客户端的状态。生成的令牌可以用于任何服务器来验证访问资源的权限。
下面是一个Slice接口,它使用显式标记,来验证用户添加或删除购物车中物品的权限:
interface SecurityTokenFactory
{
string createSecurityToken(string userId, string password);
}
exception PermissionDeniedException
{
string reason;
}
interface CartManager
{
void addItem(string itemId, int quantity, string token) throws PermissionDeniedException;
void removeItem(string itemId, string token) throws PermissionDeniedException;
}
客户端应用程序先通过调用安全令牌工厂的createSecurityToken从服务器请求一个令牌。然后,使用返回的令牌调用addItem或removeItem来修改用户的购物车。在这两个方法中会实现检查令牌,以确保它是有效的。如果服务器中的令牌检查失败,则会抛出异常。
本示例中的身份验证基于用户标识和密码,但也可能基于任何其他类型的证书(证书通常会以字节序列的形式提供)。如果客户端使用X.509证书与服务器建立SSL/TLS连接,则身份验证可以使用客户端证书中包含的标识(专有名称)。
1.1.1. 服务端实现
CartManager的Java实现示例,如下所示:
class SecurityTokenFactoryImpl implements Demo.SecurityTokenFactory
{
public String createSecurityToken(String userId, String password, com.zeroc.Ice.Current current)
{
return generateToken(userId, password); // 检查用户ID和密码,然后生成令牌
}
}
class CartManagerImpl implements Demo.CartManager
{
public void addItem(String itemId, int quantity, String token, com.zeroc.Ice.Current current) throws PermissionDeniedException
{
checkToken(token); // 确保给定的令牌可用
String userId = getUserIdFromToken(token);
java.util.Map<String, Integer> cart = _carts.get(userId);
if(cart == null)
{
cart = new java.util.HashMap<String, Integer>()
_carts.put(userId, cart);
}
cart.put(itemId, quantity);
}
public void removeItem(String itemId, int quantity, String token, com.zeroc.Ice.Current current) throws PermissionDeniedException
{
checkToken(token); // 确保给定的令牌可用
java.util.Map<String, Integer> cart = _carts.get(getUserIdFromToken(token));
if(cart != null)
{
cart.remove(itemId);
}
}
// 用户ID到购物车的MAP映射,其中购物车是物品ID到数量的MAP映射
private java.util.Map<String, java.util.Map<String, Integer>> _carts;
}
我们实现了Slice生成的接口Demo.SecurityTokenFactory和Demo.CartManager。 createSessionToken通过证书调用generateToken方法。如果用户证书有效,则此方法返回字符串令牌。令牌是各种数据的加密表示,例如:用户ID和限制令牌使用寿命的时间戳。服务器稍后通过解密令牌来检索这些数据。
展示令牌生成超出了本文的范围。令牌生成和验证通常由第三方库处理。
addItem和removeItem方法用于修改用户的购物车。令牌被明确地提供给每个调用,并且服务器调用checkToken以确保其有效性。同样,这里没有显示checkToken的实现,但通常这种方法用于解密令牌数据,验证它的有效性并检查用户的权限。由于令牌包含用户标识符,我们可以从令牌中提取它,用以检索用户的购物车并添加或删除项目。
下面是演示如何创建一个Ice communicator,Ice对象适配器,然后,注册两个服务的主要方法:
public static void main(String args[])
{
com.zeroc.Ice.Communicator communicator = com.zeroc.Ice.Util.initialize(args);
com.zeroc.Ice.ObjectAdapter adapter = communicator.createObjectAdapterWithEndpoints("CartAdapter", "ssl -p 10000");
adapter.add(new SecurityTokenFactoryImpl(), new com.zeroc.Ice.Identity("SecurityTokenFactory", ""));
adapter.add(new CartManagerImpl(), new com.zeroc.Ice.Identity("CartManager", ""));
communicator.waitForShutdown();
}
SSL/TLS传输的其他配置(例如服务器证书)需要通过Ice配置文件提供。上面定义的端点允许客户端通过端口10000上的SSL/TLS传输访问服务器。由于不指定-h,服务器将监听主机上可用的所有网络接口。
1.1.2. 客户端实现
用Ice管理器服务很简单。
下面是一个简单的客户端,该客户端获取安全令牌,然后,通过调用CartManager的Ice对象,来添加和删除购物车中的物品:
public static void main(String args[])
{
com.zeroc.Ice.Communicator com = com.zeroc.Ice.Util.initialize(args);
Demo.SecurityTokenFactoryPrx factory = Demo.CartManagerPrx.uncheckedCast(com.stringToProxy("SecurityTokenFactory:ssl -p 10000 -h 127.0.0.1"));
Demo.CartManagerPrx cartManager = Demo.CartManagerPrx.uncheckedCast(com.stringToProxy("CartManager:ssl -p 10000 -h 127.0.0.1"));
try
{
String token = factory.createSecurityToken("foo", "dummy");
cartManager.addItem("item1", 2, token);
cartManager.removeItem("item1", token);
}
catch(PermissionDeniedException ex)
{
// 处理权限被拒
}
catch(com.zeroc.Ice.LocalException ex)
{
// 处理通讯失败
}
com.destroy();
}
客户端使用Ice对象标识CartManager和端点ssl -p 10000 -h 127.0.0.1创建一个代理,我们这里假设服务器和客户端在同一台机器上运行;如果客户端和服务器位于不同的主机上,我们将使用带-h选项服务器的IP地址或主机名。我们通常还会使用此字符串化的代理配置属性,而不是将其硬编码在客户端的源码中。
1.1.3. 改进
使用Ice上下文
在CartManager中明确地传递令牌很麻烦,安全上下文不是应用程序的远程API应该关心的东西。更好的方法是,使用请求上下文在每个Slice方法签名中,没有明确token字符串参数的情况下,向每个调用发送安全令牌:
interface CartManager
{
void addItem(string itemId, int quantity) throws PermissionDeniedException;
void removeItem(string itemId) throws PermissionDeniedException;
}
客户端使用隐式上下文来传递令牌:
String token = cartManager.createSecurityToken("foo", "dummy");
// Setup an implicit context to provide the security token
java.util.Map<String, String> context = new java.util.HashMap<String, String>();
context.push("securityToken", token);
communicator.getImplicitContext().setContext(context);
cartManager.addItem("foo", "item1", 2);
cartManager.removeItem("foo", "item1");
Ice communicator也需要配置为使用Ice.ImplicitContext属性的隐式上下文。
隐式上下文的替代方案是在代理上设置上下文:
java.util.Map<String, String> context = new java.util.HashMap<String, String>();
context.push("securityToken", token);
cartManager = cartManager.ice_context(context);
现在,在这个新的CartManager代理上进行的调用将始终嵌入配置的上下文。如果你仅使用少量代理,此解决方案运作良好。
在服务器端,CartManager的服务,可以使用Ice的当前参数来检索上下文:
public void addItem(String userId, String itemId, int quantity, com.zeroc.Ice.Current current) throws PermissionDeniedException
{
checkToken(current.ctx.get("securityToken")); // Ensures the given token is valid and generated for the given user
...
}
使用调度拦截器
我们可以做的另一个改进是从CartManager接口的实现中删除安全检查。我们可以使用Ice调度拦截器,来拦截CartManagerIce对象上的请求。令牌上的安全检查将不再在addItem和removeItem的实现中显式完成。相反,拦截器的实现会处理它:
class InterceptorImpl extends com.zeroc.Ice.DispatchInterceptor
{
public InterceptorImpl(CartManagerImpl cartManager)
{
_cartManager = cartManager;
}
public java.util.concurrent.CompletionStage<com.zeroc.Ice.OutputStream> dispatch(Request request)
{
checkToken(request.getCurrent().ctx.get("securityToken"));
return _cartManager.ice_dispatch(request);
}
private CartManagerImpl _cartManager;
}
1.2. 基于连接的安全
Ice支持与基于TCP传输(tcp,ssl,ws,wss)的持续连接,因此我们也可以通过与传入请求相关的连接,来跟踪经过身份验证的客户端。通过这种方法,客户端使用用户名/密码等凭据向服务器进行身份验证。然后,服务器检查这些凭据,并将当前网络连接与它们关联。当客户端向服务器发送请求时,服务器可以检查用于发送请求的连接,并查看连接是否已知。根据相关的凭据,它可以拒绝或接受请求。
我们来更新前面例子中的Slice接口,来使用连接代替令牌:
exception PermissionDeniedException
{
string reason;
}
interface CartManager
{
void login(string userId, string password) throws PermissionDeniedException;
void logout();
void addItem(string itemId, int quantity) throws PermissionDeniedException;
void removeItem(string itemId) throws PermissionDeniedException;
}
1.2.1. 服务端实现
CartManager的Java实现示例,如下所示:
class CartManagerImpl implements Demo.CartManager
{
synchronized public void login(String userId, String password, com.zeroc.Ice.Current current)
{
checkCredentials(userId, password);
_connections.put(current.con, userId);
}
synchronized public void logout(com.zeroc.Ice.Current current)
{
_connections.remove(current.con);
}
synchronized public void addItem(String itemId, int quantity, String token, com.zeroc.Ice.Current current) throws PermissionDeniedException
{
String userId = checkConnectionAndGetUserId(current.con); // Ensures the connection is authenticated
java.util.Map<String, Integer> cart = _carts.get(userId);
if(cart == null)
{
cart = new java.util.HashMap<String, Integer>()
_carts.put(userId, cart);
}
cart.put(itemId, quantity);
}
synchronized public void removeItem(String itemId, int quantity, String token, com.zeroc.Ice.Current current) throws PermissionDeniedException
{
String userId = checkConnectionAndGetUserId(current.con); // Ensures the connection is authenticated
java.util.Map<String, Integer> cart = _carts.get(userId);
if(cart != null)
{
cart.remove(itemId);
}
}
private String checkConnectionAndGetUserId(com.zeroc.Ice.Connection connection) throws PermissionDeniedException
{
String userId = _connections.get(connection);
if(userId == null)
{
throw new PermissionDeniedException("unknown connection");
}
return userId;
}
// A map of userId -> Cart where Cart is a map of item ID -> Quantity
private java.util.Map<String, java.util.Map<String, Integer>> _carts;
// A map of Ice.Connection to user ID
private java.util.Map<com.zeroc.Ice.Connection, String> _connections;
}
正如你所看到的,与基于令牌的方法不同;现在,服务器需要使用新的用户ID连接映射来保持客户端状态。当客户端调用login方法时,此映射将被填充。该实现通过调用checkCredentials来验证凭证。我们在这里不展示这个方法的实现,但通常这个方法会执行数据库查找来检查密码。一旦证书检查完成,实现将从Ice当前对象获得的连接添加到Map,并将用户ID与连接相关联。 logout方法的实现很简单:它只是删除映射条目。
addItem和removeItem方法检索连接,并获取与连接关联的用户标识以检索用户的购物车。
1.2.2. 客户端实现
下面是客户端示例,该客户端使用上面定义的CartManager接口:
public static void main(String args[])
{
com.zeroc.Ice.Communicator com = com.zeroc.Ice.Util.initialize(args);
Demo.CartManagerPrx cartManager = Demo.CartManagerPrx.uncheckedCast(com.stringToProxy("CartManager:ssl -p 10000 -h 127.0.0.1"));
try
{
cartManager.login("foo", "dummy");
cartManager.addItem("item1", 2, token);
cartManager.removeItem("item1", token);
cartManager.logout();
}
catch(PermissionDeniedException ex)
{
// Handle permission denied
}
catch(com.zeroc.Ice.LocalException ex)
{
// Handle communication failure
}
com.destroy();
}
采用这种方法,客户端与服务器的连接保持打开状态至关重要。如果连接已关闭,并且创建了新的连接,则客户端向服务器发送的进一步请求将失败,除非客户端调用login对新连接进行身份验证。
这种基于连接的方法需要对Ice连接管理有一个很好的理解。例如,在上面的客户端中,CartManager代理对象上的调用将用相同底层的Ice连接,因为它们是在一个接一个的在同一个代理上进行的。如果addItem和removeItem调用之间存在60秒的延迟(使用Ice的默认配置),则Ice活动连接管理将关闭连接。然后,removeItem调用将失败,因为它的调用在一个新的未认证的连接上。
1.2.3. 改进
使用ACM心跳
Ice提供配置属性,来保持Ice连接的开放和活跃,并避免空闲连接的自动关闭。
例如,你可以为客户端和服务端设置以下属性:
# Client
Ice.ACM.Close=0 # CloseOff
Ice.ACM.Heartbeat=3 # HeartbeatAlways
Ice.ACM.Timeout=30
# Server
Ice.ACM.Close=4 # CloseOnIdleForceful
Ice.ACM.Heartbeat=0 # HeartbeatOff
Ice.ACM.Timeout=30
有了这个配置:
客户端在空闲时不会关闭连接,而是每隔15秒发送一次心跳消息来为连接保活,
服务端将强制关闭连接,如果它在30秒内没有收到连接的活动。
有关这些配置属性的更多信息,请参阅Ice活动连接管理。
使用连接回调
Ice还提供了一个回调机制,来通知应用程序的连接关闭。这样的关闭通知对于防止泄漏非常有用。在上面显示的服务器实现中,如果客户端没有调用logout(因为它崩溃或网络连接被丢弃),客户端连接的条目将不会从_connectionsMap中删除。这会在服务器中造成泄漏。
为防止这种泄漏,我们可以修改login方法,以确保在连接关闭时删除条目:
class CartManagerImpl implements Demo.CartManager
{
synchronized public void login(String userId, String password, com.zeroc.Ice.Current current)
{
checkCredentials(userId, password);
_connections.put(current.con, userId);
current.con.setCloseCallback(_callback);
}
...
private com.zeroc.Ice.ConnectionCallback _callback = new com.zeroc.Ice.CloseCallback()
{
public void closed(com.zeroc.Ice.Connection con)
{
synchronized(CartManagerImpl.this)
{
_connections.remove(con);
}
}
}
}
基于会话的安全性
在这种模式下,客户端与服务器建立会话。认证信息由客户端在会话建立时发送。如果认证成功,则服务器创建会话,并向客户端返回会话标识符。然后,客户端使用会话标识符在服务器应用程序上执行进一步的请求。服务器将会话信息保存在内存或数据库中。除了用户凭证之外,会话还存储特定应用程序的额外信息。例如,在购物车类型的应用程序中,会话可以存储用户的购物车。
以下是我们如何为这样的应用程序写入Slice接口:
module Demo
{
interface Session
{
void addItem(string itemId, int quantity);
void removeItem(string itemId, int quantity);
void destroy();
}
interface SessionFactory
{
Session* createSession(string username, string password);
}
}
Ice的面向对象特性使得理解客户端和服务器之间的交互变得容易。正如你所看到的,传输会话标识符也没有增加混乱。 会话标识符被createSession调用返回的会话代理隐式封装起来。
我们来看看这些接口如何实现和使用。
服务端实现
以下是会话接口的示例实现:
class SessionImpl implements Demo.Session
{
public SessionImpl(String userId)
{
_userId = userId;
}
synchronized public void addItem(String itemId, int quantity, com.zeroc.Ice.Current current)
{
_cart.add(itemId, quantity);
}
synchronized public void removeItem(String itemId, com.zeroc.Ice.Current current)
{
_cart.remove(itemId);
}
public void destroy(com.zeroc.Ice.Current current)
{
current.adapter.remove(current.id);
}
private String _userId;
private java.util.Map<String, Integer> _cart = new java.util.Map<String, Integer>();
}
会话信息由SessionImpl对象的实例存储在内存中。每个客户端会话有一个这样对象的实例。Ice客户端调用addItem和removeItem方法来添加或删除购物车中的物品。客户端调用destroy方法来通知服务器不再对会话感兴趣。该方法的实现,从Ice对象适配器中取消注册Ice对象;此后,Ice不再接受对此会话对象的请求。
current.id是Ice对象的Ice对象标识。让我们来看看如何创建会话Ice对象,来更好地理解这个标识是什么:
class SessionFactoryImpl implements Demo.SessionFactory
{
public Demo.SessionPrx createSession(String userId, String password, com.zeroc.Ice.Current current)
{
checkUserNameAndPassword(userId, password); // Perform authentication check on username/password
return Demo.SessionPrx.uncheckedCast(current.adapter.addWithUUID(new SessionImpl(userId)));
}
}
createSession方法的实现检查客户端提供的凭据。在这个例子中,客户端提供了一个用户标识符和密码。同样,它也可以提供另一种类型的证书,例如X.509证书。
然后,此方法将创建一个新的SessionImpl服务对象,并通过Ice对象适配器来注册这个服务,使用UUID作为对象标识。这个Ice对象的代理被传回给客户端。此代理嵌入了Ice对象标识和端点信息,允许客户端远程访问此Ice会话的对象。
该代理实际上是传统Web应用程序将传回客户端的会话标识符。用于Ice对象身份UUID的随机性,确保了其他客户端不能轻易猜测到它,保证客户端和服务器之间的安全通信,网络连接上的窃听也无法发现代理标识。
服务器的main方法类似于我们用于基于令牌模型的方法:
public static void main(String args[])
{
com.zeroc.Ice.Communicator communicator = com.zeroc.Ice.Util.initialize(args);
com.zeroc.Ice.ObjectAdapter adapter = communicator.createObjectAdapterWithEndpoints("SessionAdapter", "ssl -p 10000");
adapter.add(new SessionFactoryImpl(), new com.zeroc.Ice.Identity("SessionFactory", ""));
communicator.waitForShutdown();
}
客户端实现
下面是一个简单的客户端,创建一个会话,并更新会话的购物车。
public static void main(String args[])
{
com.zeroc.Ice.Communicator communicator = com.zeroc.Ice.Util.initialize(args);
com.zeroc.Ice.ObjectPrx proxy = communicator.stringToProxy("SessionFactory:ssl -p 10000 -h 127.0.0.1");
Demo.SessionFactoryPrx factory = Demo.SessionFactoryPrx.uncheckedCast(proxy);
try
{
Demo.SessionPrx session = factory.createSession("foo", "dummy");
session.addItem("item1", 2);
session.removeItem("item1");
session.destroy();
}
catch(com.zeroc.Ice.LocalException ex)
{
// Handle communication failure
}
communicator.destroy();
}
客户端为会话工厂的Ice对象创建代理,并调用createSession来提供凭据和创建会话。然后,客户端可以使用会话代理来添加和删除购物车中的物品。最后,它会调用会话的destroy来确保服务器为会话分配的资源被销毁。
1.2.4. 改进
会话生命周期
在上面的服务器实现中,我们依靠客户端调用会话destroy()方法来释放分配给会话的资源。该实现从Ice对象适配器中删除服务。如果不调用destroy(),则服务将保持被Ice对象适配器引用,这将导致对象保留在内存中,直到服务器关闭时,Ice对象适配器才会被销毁。
由于不能保证客户端会调用destroy()(客户端可能会崩溃,网络连接可能会丢失,...),所以当我们检测到客户端放弃了会话时,我们需要添加一个机制来删除会话调用destroy()。Ice服务器如何检测到这个?有几个解决方案:
服务器要求客户端定期显式
ping或保持活动会话。服务器跟踪来自客户端的ping,收集一段时间内没有收到ping消息的会话。这可以通过在客户端设置一个定时器来调用代理上的ice_ping()方法,并覆盖Demo.SessionImpl类中的ice_ping服务方法来实现。服务器会定期检查哪些会话,可以根据最后一次收到ping的情况收回。应用程序可以将会话绑定到Ice连接(表示客户端和服务器之间的持久网络连接),并依靠连接关闭回调来销毁会话。这种方法要求客户端使用单个连接在服务器上调用。除非客户明确请求使用单独的连接,或使用不同的连接超时或端点。
将会话绑定到客户端连接是最简单的解决方案。以下是我们如何修改会话工厂,通过设置连接回调来清理连接关闭时的会话:
class SessionFactoryImpl implements Demo.SessionFactory
{
public Demo.SessionPrx createSession(String userId, String password, com.zeroc.Ice.Current current)
{
checkUserNameAndPassword(userId, password); // Perform authentication check on username/password
Demo.SessionPrx sessionPrx = Demo.SessionPrx.uncheckedCast(current.adapter.addWithUUID(new SessionImpl(userId)));
current.con.setCloseCallback(new com.zeroc.Ice.CloseCallback()
{
public void closed(com.zeroc.Ice.Connection con)
{
current.adapter.remove(sessionPrx.ice_getIdentity());
}
});
return sessionPrx;
}
}
还有一个额外的考虑:closed的回调被调用的速度有多快。如果操作系统的TCP/IP堆栈未及时检测到连接故障,则可能在连接失败几个小时后,调用回调closed方法。当客户端与服务器的连接经过多个路由器,并且由于连接路径上预期的故障而导致连接失败时,通常情况就是如此。为了使服务器及时释放会话资源,快速检测到连接丢失,可以在连接上启用保活。在Ice手册的活动连接管理部分,提供了有关该主题的更多信息。
增加安全性
上面的方法依赖于,其他客户端不能发现,用于会话的Ice对象身份的UUID。在上面的改进中,我们看到将会话绑定到连接是在客户端消失时,清除会话资源的简单方法。为了提供更高的安全性,我们还可以依靠Ice连接对象,来检查会话上的调用是否仅由创建会话的客户端执行。这需要跟踪会话中的Ice连接对象,并在每个调用中检查这个连接:
class SessionImpl implements Demo.Session
{
public SessionImpl(String userId, com.zeroc.Ice.Connection connection)
{
_userId = userId;
_connection = connection;
}
synchronized public void addItem(String itemId, int quantity, com.zeroc.Ice.Current current)
{
checkConnection(current);
_cart.add(itemId, quantity);
}
synchronized public void removeItem(String itemId, com.zeroc.Ice.Current current)
{
checkConnection(current);
_cart.remove(itemId);
}
void destroy(com.zeroc.Ice.Current current)
{
checkConnection(current);
current.adapter.remove(current.id);
}
private void checkConnection(com.zeroc.Ice.Current current)
{
//
// If the connection doesn't match the one used to create the session, we return
// ObjectNotExistException to the client.
//
if(current.con != _connection)
{
throw new com.zeroc.Ice.ObjectNotExistException(current.id, current.facet, current.operation);
}
}
private String _userId;
private com.zeroc.Ice.Connection _connection;
private java.util.Map<String, Integer> _cart = new java.util.Map<String, Integer>();
}
使用调度拦截器
就像基于令牌的模型一样,我们可以进一步改进上面的会话实现,并将checkConnection调用移动到调度拦截器:
class InterceptorImpl extends com.zeroc.Ice.DispatchInterceptor
{
InterceptorImpl(SessionImpl session)
{
_session = session;
}
public java.util.concurrent.CompletionStage<com.zeroc.Ice.OutputStream> dispatch(com.zeroc.Ice.Request request)
{
_session.checkConnection(current);
return _session.ice_dispatch(request);
}
private SessionImpl _session;
}