Web 应用程序中的自由访问控制列表 (ACL) 授权安全模型(使用 NHibernate)
一种实用的对象级别安全方法。
引言
本文介绍了一种用于在具有 NHibernate 的 Web 应用程序中进行数据访问的实用且安全模型。具体来说,我们将生成一个由 NHibernate 2.0 事件系统拦截的任意访问模型。我们将检查拦截器和事件的用法。尽管相当直接,但这是一种非常强大的架构,它提供了一个健壮且安全的数据访问层 (DAL) 以及一个每类/实例的方法,并且代码量最少。
背景
在典型的 Web 或 SOA 架构中,首先会对调用者进行身份验证,并根据用户的凭据创建安全上下文。此上下文可在请求或会话期间使用。许多 Web 服务然后会验证此用户是否被允许执行 Web 服务 API 后面的特定操作。
例如,在一个博客系统中,我们会实现一个名为 SaveBlogPost
的方法,并检查调用用户是否是授权的作者,否则抛出异常。另一个调用 GetBlogPost
会检查读取权限,而 GetAllBlogPosts
则必须遍历结果列表并检查访问权限。在我看来,这是一种非常繁琐的实现。
与 Web 服务或 Web 应用程序相比,文件系统使用 ACL。访问控制列表 (ACL) 是附加到对象的权限列表。在基于 ACL 的安全模型中,当主体请求在对象上执行操作时,操作系统首先检查列表以查找适用的条目,以决定是否继续执行该操作。无论是本地访问文件、通过网络访问、Web 服务还是 COM 访问,ACL 系统都负责文件的安全性。
我们如何在 Web 应用程序中实现类似的基于 ACL 的文件系统模型?
实现
对象模型
我们将使用 NHibernate 2.0 实现一个基于 ACL 的模型。我选择了以下简单的数据库对象模型来进行博客系统开发:
Account
代表已通过身份验证的用户。在我们的系统中,任何人都可以注册,即创建账户。Blog
有一个账户所有者和一个可变的作者列表。在我们的系统中,任何已通过身份验证的用户都可以创建博客。BlogAuthor
代表个人贡献者或账户。在我们的系统中,博客所有者可以添加或删除贡献者。贡献者可以自行退出贡献者身份,无需进一步验证。BlogPost
可以被所有人看到。博客所有者可以编辑所有帖子,博客作者只能编辑自己的帖子。
我们希望设计一个编程模型,在该模型中,我们可以用 C# 代码以直接、简短且简洁的方式表示上述授权要求。我们希望写出“任何人都可以创建此对象”或“博客作者可以删除自己的帖子”这样一行 C# 代码。当我们成功时,这一行 C# 代码可以轻松扩展到基于配置的实现,其中权限在 XML 文件中描述。
NHibernate 数据层
将数据存入数据库和从中取出数据库是一个琐碎的问题,许多框架都可以解决。我选择了 Puzzle Framework 来设计领域模型、为 NHibernate 导出 C# 类(包括 .cs 类实现和 .hbm.xml NHibernate 映射),并制作上面的 UML 图。我还添加了一个通用的 NHibernateCrudTest
单元测试,以确保所有 CRUD 操作(创建、检索、更新和删除)都可以进行。这是 DAL,没有任何访问控制。
这是一个 Blog
类的示例。请注意,我修改了 Account
属性以不允许任何更新;否则,这是 Puzzle 生成的代码。这是通过设计来禁止更改博客所有权的一种方式。
public class Blog: IDataObject
{
private System.Int32 _Id;
private Account _Account;
private System.Collections.Generic.IList<blogauthor> _BlogAuthors;
private System.Collections.Generic.IList<blogpost> _BlogPosts;
private System.DateTime _Created;
private System.String _Description;
private System.String _Name;
public virtual System.Int32 Id { get { return _Id; } }
public virtual Account Account
{
get { return _Account; }
set
{
if (_Account != null)
{
throw new InvalidOperationException();
}
_Account = value;
}
}
public virtual System.Collections.Generic.IList<blogauthor> BlogAuthors
{
get { return _BlogAuthors; }
set { _BlogAuthors = value; }
}
public virtual System.Collections.Generic.IList<blogpost> BlogPosts
{
get { return _BlogPosts; }
set { _BlogPosts = value; }
}
public virtual System.DateTime Created
{
get { return _Created; }
set { _Created = value; }
}
public virtual System.String Description
{
get { return _Description; }
set { _Description = value; }
}
public virtual System.String Name
{
get { return _Name; }
set { _Name = value; }
}
}
以下 NHibernate 映射是为 Blog
类自动生成的
<?xml version="1.0" encoding="utf-8" ?>
<hibernate-mapping xmlns="urn:nhibernate-mapping-2.2" schema="dbo">
<class name="Vestris.Data.NHibernate.Blog, Data.NHibernate" table="Blog">
<id name="Id" type="Int32"
column="Blog_Id" access="field.pascalcase-underscore">
<generator class="identity" />
</id>
<many-to-one name="Account" column="Account_Id"
class="Vestris.Data.NHibernate.Account, Data.NHibernate" />
<bag name="BlogAuthors" inverse="true">
<key column="Blog_Id" />
<one-to-many class="Vestris.Data.NHibernate.BlogAuthor, Data.NHibernate" />
</bag>
<bag name="BlogPosts" inverse="true">
<key column="Blog_Id" />
<one-to-many class="Vestris.Data.NHibernate.BlogPost, Data.NHibernate" />
</bag>
<property name="Created" column="Created" type="DateTime" />
<property name="Description" column="Description" type="String" />
<property name="Name" column="Name" type="String" />
</class>
</hibernate-mapping>
NHibernate 拦截器
由于我们的目标是在尽可能低的级别和对象级别进行授权,因此我们必须拦截对 DAL 之上的或之下的所有对象的访问。一个显而易见的选择是用实现身份验证的类包装每个 DAL 类,但 NHibernate 通过使用实现 IInterceptor
的类提供了一个更具吸引力的模型。IInterceptor
接口定义了一个虚拟钩子到 CRUD 功能。还提供了一个空的实现 NHibernate.EmptyInterceptor
,可以从中派生以实现部分方法而不是全部方法。例如,现在我们可以通过控制台看到所有对象的生命周期。
public class ServiceDataInterceptor : EmptyInterceptor
{
// save an existing instance (flush dirty data)
public override bool OnFlushDirty(object entity, object id,
object[] currentState, object[] previousState,
string[] propertyNames, NHibernate.Type.IType[] types)
{
Console.WriteLine("FlushDirty: {0}:{1}", entity, id);
return base.OnFlushDirty(entity, id, currentState,
previousState, propertyNames, types);
}
// save a new instance
public override bool OnSave(object entity, object id,
object[] state, string[] propertyNames,
NHibernate.Type.IType[] types)
{
Console.WriteLine("Save: {0}:{1}", entity, id);
return base.OnSave(entity, id, state, propertyNames, types);
}
// load an existing instance
public override bool OnLoad(object entity, object id,
object[] state, string[] propertyNames,
NHibernate.Type.IType[] types)
{
Console.WriteLine("Load: {0}:{1}", entity, id);
return base.OnLoad(entity, id, state, propertyNames, types);
}
// delete an existing instance
public override void OnDelete(object entity, object id,
object[] state, string[] propertyNames,
NHibernate.Type.IType[] types)
{
Console.WriteLine("Delete: {0}:{1}", entity, id);
base.OnDelete(entity, id, state, propertyNames, types);
}
}
在创建会话工厂时,会将其钩接到会话工厂。
public ISessionFactory GetSessionFactory()
{
Configuration cfg = new Configuration();
cfg.Properties.Add("dialect", "NHibernate.Dialect.MsSql2000Dialect");
cfg.Properties.Add("connection.provider",
"NHibernate.Connection.DriverConnectionProvider");
cfg.Properties.Add("connection.driver_class",
"NHibernate.Driver.SqlClientDriver");
cfg.Properties.Add("connection.connection_string", "...");
cfg.Interceptor = new ServiceDataInterceptor();
cfg.AddAssembly("...");
return cfg.BuildSessionFactory();
}}
NHibernate 事件
另一种钩入 NHibernate 管道的方法是使用事件侦听器。这比拦截器更详细的框架,因为几乎每个 ISession
方法都有相应的事件。可以在运行时配置会话工厂,使其具有多个侦听器。这是 NHibernate 2.0 中的一项新功能。
private T[] Insert<T>(T[] listeners, T instance)
{
if (instance == null)
return listeners;
List<T> newListeners = new List<T>();
newListeners.Add(instance);
newListeners.AddRange(listeners);
return newListeners.ToArray();
}
...
cfg.EventListeners.PostLoadEventListeners =
Insert(cfg.EventListeners.PostLoadEventListeners,
new MyPostLoadEventListener());
它也是在对象加载后访问对象的唯一方式,而拦截器则试图在实例被修改之前捕获属性。正如您将在下面的 ACL 中看到的,拦截器的这一限制使其对我们的目的无效,因此我们将使用事件系统。
用户身份
在实现任何实际授权之前,我们必须回答两个基本问题:
- 什么定义了一个用户?
- 我们如何在会话中创建和携带安全上下文?
在我们的实现中,用户需要首先创建,向 Account 表添加一行。我选择实现一个 IdentityService
,该服务可以通过查找此记录来登录用户。此服务可以轻松替换为其他身份验证方案,例如,将用户登录到 Active Directory,然后根据 SID 在 Account 表中查找记录。出于演示目的,安全上下文和身份服务都是基础的;在“现实世界”中,这应该是一个健壮的、基于接口的、可插拔的提供程序实现。
public class UserContext : EmptySessionContext
{
private int _accountId = 0;
private DateTime _timestamp = DateTime.UtcNow;
public int AccountId { get { return _accountId; } }
public DateTime TimeStamp { get { return _timestamp; } }
public UserContext(Account account)
{
_accountId = account.Id;
}
}
public class IdentityService
{
private ISession _session = null;
public IdentityService(ISession session)
{
_session = session;
}
public UserContext Login(string username, string password)
{
Account account = _session.CreateCriteria(typeof(Account))
.Add(Expression.Eq("Name", username))
.Add(Expression.Eq("Password", password))
.UniqueResult<Account>();
if (account == null)
{
throw new AccessDeniedException();
}
return new UserContext(account);
}
}
现在必须在请求的整个生命周期中提供已创建的安全上下文。Web 服务模型通常公开一个单例 SessionManager
。我们将需要一个类似的用于非基于 Web 的单元测试的基础,可能是线程安全的。
public abstract class SessionManager
{
[ThreadStatic]
private static ISession _currentSession = null;
[ThreadStatic]
private static ISessionContext _currentSessionContext = null;
public static ISession CurrentSession
{
get { return _currentSession; }
set { _currentSession = value; }
}
public static ISessionContext CurrentSessionContext
{
get { return _currentSessionContext; }
set { _currentSessionContext = value; }
}
}
文章源代码中附带的实际代码进行了一些更复杂的生产级改进。首先,提供了一个会话源,该会话源可以根据执行上下文是基于 HTTP、线程还是用户上下文的。这使得可以根据此想法在各种线程或管道要求中无缝集成。单元测试使用基于用户上下文的会话源,以及一个允许当前线程切换到不同用户上下文然后恢复到自身的模拟器。
在基于 Web 的场景中,ASP.NET 管道将在 Global.asax 的身份验证后初始化会话管理器,并提供一组事件处理程序。随后,它将创建登录用户上下文;任何后续代码,包括执行数据授权任务的事件处理程序,都可以获得 SessionManager.CurrentSessionContext
进行工作。以下代码在 ASP.NET 下初始化 SessionManager
,并设置默认用户上下文。
SessionManager.Initialize(new HttpSessionSource(), ServiceDataEventListeners.Instance);
SessionManager.CurrentSessionContext = new GuestUserContext();
访问控制列表
访问控制列表 (ACL) 是附加到对象的权限列表。每个条目定义了特定类型 DataOperation
的 DataOperationPermission
(允许或拒绝的数据访问类型)。ACL 然后可以对调用用户是否可以访问附加到该 ACL 的对象做出裁决。
public enum DataOperation
{
None = 0,
Create = 1,
Retreive = 2,
Update = 4,
Delete = 8,
All = Create | Retreive | Update | Delete,
AllExceptCreate = Retreive | Update | Delete,
AllExceptUpdate = Create | Retreive | Delete,
AllExceptDelete = Create | Retreive | Update
}
public enum DataOperationPermission
{
Deny,
Allow,
}
public enum ACLVerdict
{
None,
Denied,
Allowed
}
public interface IACLEntry
{
ACLVerdict Apply(UserContext ctx, DataOperation op);
}
这是否看起来应该是一个布尔的 Allowed
或 Denied
裁决?考虑一个 ACL 条目,它表示 Account
对 Blog
具有读取访问权限。这并没有说明其他账户是否可以访问同一个博客,从而产生一个 None
裁决。在一个默认拒绝访问的系统中(ACL 必须显式允许访问),ACL 中所有条目的 None
裁决意味着拒绝访问。
使用基础的 ACLBaseEntry
,我们可以专门化许多 ACL 条目,包括 ACLEveryoneAllowRetrieve
、ACLEveryoneAllowCreate
,以及一个通用的 ACLAccount
,该条目将特定权限分配给特定账户。这是一个 ACLEveryoneAllowRetrieve
的示例。
public class ACLEveryoneAllowRetrieve : ACLBaseEntry
{
public ACLEveryoneAllowRetrieve()
: base(DataOperation.Retreive, DataOperationPermission.Allow)
{
}
public override ACLVerdict Apply(UserContext ctx, DataOperation op)
{
return (op == DataOperation.Retreive) ? ACLVerdict.Allowed : ACLVerdict.None;
}
}
总的来说,ACL 是 ACLEntry
项的集合,并实现了诸如 Check
之类的方法。后者将在当前安全上下文 ctx
不允许调用者执行操作 op
时抛出 AccessDeniedException
。
public ACLVerdict Apply(UserContext ctx, DataOperation op)
{
ACLVerdict current = ACLVerdict.Denied;
foreach (IACLEntry entry in _accessControlList)
{
ACLVerdict result = entry.Apply(ctx, op);
switch (result)
{
case ACLVerdict.Denied:
return ACLVerdict.Denied;
case ACLVerdict.Allowed:
current = ACLVerdict.Allowed;
break;
}
}
return current;
}
public bool TryCheck(UserContext ctx, DataOperation op)
{
ACLVerdict result = Apply(ctx, op);
switch (result)
{
case ACLVerdict.Denied:
case ACLVerdict.None:
return false;
}
return true;
}
public void Check(UserContext ctx, DataOperation op)
{
if (!TryCheck(ctx, op))
{
throw new AccessDeniedException();
}
}
将 ACL 应用于对象
ACL 应用于对象实例。例如,给定一个 Blog
,我们需要一个继承自 ACL
的 BlogACL
。我选择了一种简单的构造方式,通过反射按名称从 NHibernate 数据类的实例创建 ACL 实例。或者,ACL 可以附加到 DAL 类本身,但我更喜欢某种程度的方面编程。
public abstract class ServiceDataAuthorizationConnector
{
public static void Check(IDataObject instance, DataOperation op)
{
string aclClassTypeName = string.Format("Vestris.Service." +
"Data.{0}ClassACL", instance.GetType().Name);
Type aclClassType = Assembly.GetExecutingAssembly().GetType(
aclClassTypeName, true, false);
object[] args = { instance };
ACL acl = (ACL) Activator.CreateInstance(aclClassType, args);
acl.Check(CurrentUserContext, op);
}
}
以下方法实现在加载事件侦听器之后,它将检查加载任何 IDataObject
实例的访问权限,通过为 Blog
实例创建 BlogClassACL
。通过使用反射,我将授权类与 NHibernate 数据分离,但最好将 ACL 成员实现到自动生成的 DAL 中,并将 ACL 方法添加到 IDataObject
接口。
public class ServiceDataPostLoadEventListener : IPostLoadEventListener
{
public void OnPostLoad(PostLoadEvent @event)
{
Debug.WriteLine(string.Format("OnPostLoad - {0}", @event.Entity));
if (@event.Entity is IDataObject)
{
ServiceDataAuthorizationConnector.Check((IDataObject) @event.Entity,
DataOperation.Retreive);
}
}
}
BlogClassACL
的实现应该是微不足道的,因为我们将不得不为每个数据类型开发一个 ClassACL
。
public class BlogClassACL : ACL
{
public BlogClassACL(Blog instance)
{
// allow every authenticated user to create a blog
this.Add(new ACLAuthenticatedAllowCreate());
// allow everyone to get information about this blog
this.Add(new ACLEveryoneAllowRetrieve());
// the owner has full privileges
this.Add(new ACLAccount(instance.Account, DataOperation.All));
}
}
上面的代码是我们正在努力实现的目标!它显示了即时优势以及这种方法的强大之处:实现者可以访问实际对象,并从中派生 ACL。它非常易读,并且自然地描述了谁可以访问 Blog
的实例,作为对少量性能损失的交换。此外,还可以“继承”ACL,如以下 BlogPostClassACL
示例所示。
public class BlogPostClassACL : ACL
{
public BlogPostClassACL(BlogPost instance)
{
// posts have the same permissions as the blog
this.Add(new BlogClassACL(instance.Blog));
// allow the author of the post to do everything with the post
this.Add(new ACLAccount(instance.Account, DataOperation.AllExceptCreate));
// allow blog authors to create posts
if (instance.Blog.BlogAuthors != null)
{
foreach (BlogAuthor author in instance.Blog.BlogAuthors)
{
this.Add(new ACLAccount(author.Account, DataOperation.Create));
}
}
}
}
您必须小心继承。上面的代码实际上是错误的。博客的 ACL 允许任何已通过身份验证的用户创建博客。继承 ACL 意味着任何人都可以创建帖子,这是不正确的——只有博客所有者或博客作者才能创建帖子。
ASP.NET
成员资格提供程序
ASP.NET 提供了一个简单而强大的成员资格提供程序模型用于身份验证,我们将按部就班地使用它。我们的 IdentityServiceMembershipProvider
服务于 IdentityServiceMembershipUser
对象实例。以下是相关部分:
public class IdentityServiceMembershipUser : MembershipUser
{
public IdentityServiceMembershipUser(Account account) { }
public override DateTime CreationDate { get { ...; } }
public override string UserName { get { ...; } }
}
public class IdentityServiceMembershipProvider : MembershipProvider
{
private SessionFactory _sessionFactory = new SessionFactory(null);
public override bool ValidateUser(string username, string password)
{
IdentityService identityService =
new IdentityService(_sessionFactory.Instance.OpenSession());
return identityService.TryLogin(username, password);
}
public override MembershipUser CreateUser(string username, string password,
string email, string passwordQuestion, string passwordAnswer,
bool isApproved, object providerUserKey,
out MembershipCreateStatus status)
{
IdentityService identityService =
new IdentityService(_sessionFactory.Instance.OpenSession());
MembershipUser user = new IdentityServiceMembershipUser(
identityService.CreateUser(username, password));
status = MembershipCreateStatus.Success;
return user;
}
public override MembershipUser GetUser(string username, bool userIsOnline)
{
IdentityService identityService =
new IdentityService(_sessionFactory.Instance.OpenSession());
return new IdentityServiceMembershipUser(
identityService.FindUser(username));
}
}
成员资格提供程序在 Web.config 中配置。我们将使用表单身份验证,默认情况下拒绝访问所有页面,并在其上方定义一个自定义成员资格提供程序,该提供程序公开我们的数据库用户。这实现了登录,并实现了身份验证。
<system.web>
<authorization>
<deny users="?" />
</authorization>
<authentication mode="Forms" />
<membership defaultProvider="IdentityServiceMembershipProvider">
<providers>
<add name="IdentityServiceMembershipProvider"
type="IdentityServiceMembershipProvider" />
</providers>
</membership>
...
</system.web>
ASP.NET 管道在 Global.asax 的身份验证后为我们提供单个请求。我们可以根据每个请求初始化 SessionManager
,并使用身份验证系统返回的用户上下文。这使得所有内容都联系在一起。
public void Application_Start(Object sender, EventArgs e)
{
SessionManager.Initialize(new HttpSessionSource(),
ServiceDataEventListeners.Instance);
}
public void Application_PostAuthenticateRequest(Object sender, EventArgs e)
{
IdentityServiceMembershipUser user =
(IdentityServiceMembershipUser) Membership.GetUser();
if (user == null || user.Account == null)
{
SessionManager.CurrentSessionContext = new GuestUserContext();
}
else
{
SessionManager.CurrentSessionContext = new UserContext(user.Account);
}
}
演示
该项目包含一个基础的博客系统。运行它的最简单方法是从 Visual Studio 2008 开始。您首先需要创建一个名为 OLS 的数据库,并使用包中的 ols.sql 脚本填充它。尝试注册用户、登录并创建博客和帖子。创建另一个用户,并尝试发布到第一个用户的博客(您可以在首页上看到其他人的博客)。您将收到一个访问被拒绝的异常。
Using the Code
您可以稍作修改即可重用此代码的多个部分。
- Service.NHibernate:包含会话工厂、上下文、事件侦听器、管理器、打开和存储。库的配置部分将需要针对您的应用程序进行更改,但其余部分可以“开箱即用”。
- Service.Identity:包含身份和用户上下文对象;您需要更改代码以支持您的后端。
- Service.Data:包含 ACL 实现、授权连接器和数据拦截器。删除所有演示特定的 ACL 类并按原样重用。
- Blogs/Global.asax:包含 Web 应用程序的初始化代码。将初始化代码复制到您的项目中。
结论
在实现了基于 ACL 的授权框架之后,开发人员无需担心在业务逻辑中的任何地方检查访问权限。安全架构师现在可以独立地在每个类级别设计访问,将业务需求直接转化为访问控制。从安全角度来看,这不易出错,并且完全解决了任何意外检索未经授权数据的可能性。
源代码和补丁
本文和源代码的最新版本始终可以在 Subversion 中找到,网址为 svn://svn.vestris.com/codeproject/ObjectLevelSecurity。您也可以 浏览源代码。您可以在 code.dblock.org 上找到此项目的最新信息。
历史
- 2009/01/21:初始版本。