ASP.NET Core WebAPI 资源级别的授权在控制器层面之外

9

我正在创建一个具有不同角色用户的 Web API,与任何其他应用程序一样,我不希望用户A访问用户B的资源,如下所示:

Orders/1(用户A

Orders/2(用户B

当然,我可以从请求中获取JWT并查询数据库以检查该用户是否拥有该订单,但这会使我的控制器操作过于繁重。

示例使用AuthorizeAttribute,但它似乎太广泛了,我将不得不添加大量条件来检查API中所有路由,并查询数据库进行多个连接,最终返回请求是否有效。

更新

对于路由,第一道防线是安全策略,需要特定的声明。

我的问题是关于第二道防线,负责确保用户仅访问其数据/资源。

在这种情况下是否有任何标准方法可供采取?


1
您可以使用类似于 "users/{userId}/orders/{orderId}" 的路由,然后使用如下 SQL 查询语句 "where OwnerId = @userId and OrderId = @orderId"。此外,还需添加授权过滤器以检查 "userId" 路由参数值是否与已认证的用户相同。 - Sergey Vishnevskiy
1
@SergeyVishnevsky 嗯,这是一个想法,但我最终会在每个路由中都得到用户ID,而我只需从httpContext获取UserId,我还必须为不拥有订单的管理员添加自定义路由。我正在尝试找到更清晰的标准方法。 - Mozart
你想要实现什么?你想允许用户只获取他的订单吗?还是你想创建不同订单的用户角色? - Oleg Kyrylchuk
根据我所看到的,基于策略的授权多重处理程序可以帮助解决这个问题。你可以将路由参数"ownerId"用作所有路由的资源所有者指示器,并为授权要求添加两个或更多AuthorizationHandler。第一个检查"当前用户ID是否等于"ownerId"路由值",第二个检查"当前用户是否为管理员"。如果其中一个处理程序批准了该要求,则请求将获得授权。 - Sergey Vishnevskiy
@OlegKyrylchuk 请查看更新的问题。 - Mozart
@SergeyVishnevsky 我很快会检查一下。 - Mozart
6个回答

4

使用[Authorize]属性被称为声明式授权。但它在控制器或操作执行之前执行。当您需要基于资源的授权,并且文档具有作者属性时,您必须在授权评估之前从存储中加载文档。这被称为命令式授权。

Microsoft Docs上有一篇文章介绍如何处理ASP.NET Core中的命令式授权。我认为这非常全面,并且回答了您关于标准方法的问题。

此外,您可以在这里找到代码示例。


我的情况中的资源可能是文档,也可能是数据库记录。 - Mozart
@MozartAlKhateeb 是的,资源可以是任何数据库实体。 - Oleg Kyrylchuk

4

我的方法是自动限制查询当前认证用户账户所拥有的记录。

我使用一个接口来指示哪些数据记录是与账户相关的。

public interface IAccountOwnedEntity
{
    Guid AccountKey { get; set; }
}

同时提供一个接口来注入逻辑,用于确定仓库应该针对哪个账户进行操作。

public interface IAccountResolver
{
    Guid ResolveAccount();
}

我今天使用的 IAccountResolver 的实现是基于已认证用户的声明。

public class ClaimsPrincipalAccountResolver : IAccountResolver
{
    private readonly HttpContext _httpContext;

    public ClaimsPrincipalAccountResolver(IHttpContextAccessor httpContextAccessor)
    {
        _httpContext = httpContextAccessor.HttpContext;
    }

    public Guid ResolveAccount()
    {
        var AccountKeyClaim = _httpContext
            ?.User
            ?.Claims
            ?.FirstOrDefault(c => String.Equals(c.Type, ClaimNames.AccountKey, StringComparison.InvariantCulture));

        var validAccoutnKey = Guid.TryParse(AccountKeyClaim?.Value, out var accountKey));

        return (validAccoutnKey) ? accountKey : throw new AccountResolutionException();
    }
}

然后在仓库内,我将所有返回的记录限制为属于该帐户的记录。

public class SqlRepository<TRecord, TKey>
    where TRecord : class, IAccountOwnedEntity
{
    private readonly DbContext _dbContext;
    private readonly IAccountResolver _accountResolver;

    public SqlRepository(DbContext dbContext, IAccountResolver accountResolver)
    {
        _dbContext = dbContext;
        _accountResolver = accountResolver;
    }

    public async Task<IEnumerable<TRecord>> GetAsync()
    {
        var accountKey = _accountResolver.ResolveAccount();

        return await _dbContext
                .Set<TRecord>()
                .Where(record => record.AccountKey == accountKey)
                .ToListAsync();
    }


    // Other CRUD operations
}

使用这种方法,我无需记住在每个查询中应用我的帐户限制。它会自动发生。

在这种情况下,所有实体都实现了IAccountOwnedEntity接口?那么所有表格中都有用户ID吗? - Mozart
是的,没错。理论上,如果一条记录特定于某个账户,它们将在数据库中绑定在一起。 - Matt Hensley
这对我来说似乎有点过度工程化...显然,这取决于一个人可能拥有的实体数量以及有多少不同的查询...但是这是为一个而设计的两个类和两个接口。 - L.Trabacchin
当然,我可以从请求中获取JWT并查询数据库以检查该用户是否拥有该订单,但这会使我的控制器操作过于繁重。虽然我的回答也没有解决这个问题,但至少我解释了原因... - L.Trabacchin
此类方法往往会使您的项目更加僵化,因为现实世界的情况并不能都适用于一个所有者的定义,您可能需要创建者、编辑者、管理员、管理这些实体的公司等等。 我不会采用这种方法,只是我的个人意见... - L.Trabacchin

3
为了确保User A不能查看属于User BId=2订单,我会做以下两件事之一: 第一种: 定义一个GetOrderByIdAndUser(long orderId, string username)函数,并从jwt中获取用户名。 如果该用户不拥有该订单,则无法查看,也不需进行额外的数据库调用。 第二种: 首先从数据库中获取订单GetOrderById(long orderId),然后验证订单的username属性是否与jwt登录用户相同。 如果该用户不拥有该订单,则抛出异常、返回404或其他操作,并且不需要进行额外的数据库调用。
void ValidateUserOwnsOrder(Order order, string username)
{
            if (order.username != username)
            {
                throw new Exception("Wrong user.");
            }
}

2
您可以在启动配置服务的ConfigureServices方法中创建多个策略,包含角色或适合此示例的名称。示例如下:
AddPolicy("UserA", builder => builder.RequireClaim("Name", "UserA"))

或者将“UserA”替换为“会计”并将“Name”替换为“角色”。
然后按角色限制控制器方法:

[Authorize(Policy = "UserA")

当然,这又涉及到控制器级别的操作,但您不需要绕过令牌或数据库。这将直接指示哪个角色或用户可以使用哪种方法。


请查看更新的问题,角色不能保证用户A无法访问用户B的数据,也许可以以不同的方式使用策略来考虑当前会话用户在验证中的作用,我会研究一下。 - Mozart
1
另一层取决于您想要定义数据属于谁的位置。例如,您可以有一个持久化层,它将用户令牌作为附加参数,以与保存在具有该数据的数据库中的用户进行检查。 - Squirrelkiller
[Authorize]上的策略名称必须是const值,这意味着您需要为要添加的每个用户创建一个新的const值。这似乎不太适用于超过1或2个用户的情况。 - Matt Hensley
1
@MattHensley 这正是我添加“角色”概念的原因。只需将“UserA”放在那里即可在技术上符合OP的问题。 - Squirrelkiller

1
你需要回答的第一个问题是:“我什么时候可以做出授权决策?”。你何时才有足够的信息来进行检查?
如果你几乎总是可以从路由数据(或其他请求上下文)确定所访问的资源,那么具有匹配要求和处理程序的策略可能是合适的。当你与明显分离资源的数据进行交互时,它最有效 - 因为它对列表过滤等事情没有任何帮助,在这些情况下,你将不得不退回到命令式检查。
如果您无法确定用户是否能够操作资源,直到您实际检查它,那么您就只能使用命令式检查。虽然有标准框架,但我认为政策框架更有用。也许在某个时候编写一个IUserContext是有价值的,该上下文可以在查询域时注入(因此进入repos/无论您在哪里使用linq),并封装其中一些过滤器(IEnumerable<Order> Restrict(this IEnumerable<Order> orders, IUserContext ctx))。
对于复杂的域,不会有简单的解决方案。如果您使用ORM,则它可能会帮助您 - 但请不要忘记,域中可导航的关系将允许代码打破上下文,特别是如果您没有严格保持聚合隔离(myOrder.Items [n] .Product.Orderees[notme]...)。
上次我做这件事时,我成功地使用了基于路由的策略方法解决了90%的情况,但仍需要对一些复杂查询或列表进行手动检查。使用命令式检查的危险是,你可能会忘记它们。一个潜在的解决方案是在控制器级别应用您的[Authorize(Policy = "MatchingUserPolicy")],在操作中添加一个额外的策略"ISolemlySwearIHaveDoneImperativeChecks",然后在MatchUserRequirementsHandler中检查上下文,并在命令式检查已被“声明”时绕过天真的用户/订单匹配检查。

1
你的陈述是错误的,并且你的设计也有问题。 过度优化是万恶之源,这个链接可以总结为“在宣称不可行之前先测试性能”。
使用身份验证(jwt令牌或您配置的任何其他方式)来检查实际用户是否正在访问正确的资源(或者只服务于其拥有的资源)并不会太重。如果变得过重,则说明您做错了什么。可能是您有大量同时访问,只需要缓存一些数据,例如随时间清除的字典顺序->ownerid…但这似乎不是这种情况。
关于设计:创建一个可重用的服务,可以注入并具有访问所需每个资源的方法,该方法接受用户(IdentityUser、jwt主题、仅用户ID或您拥有的任何其他内容)。
例如:
ICustomerStore 
{
    Task<Order[]> GetUserOrders(String userid);
    Task<Bool> CanUserSeeOrder(String userid, String orderid);
}

实现相应的功能并使用此类来系统地检查用户是否可以访问资源。

很好,这个问题的目的是了解标准方法并指出错误之处。该API仍在建设中,在过去,我曾经将用户ID与查询一起发送到数据库。关于ICustomerStore,它是IService还是IRepo,其责任是查询数据? - Mozart
嗯,这要看情况…我的经验是尽快按照一些模式完成任务… 然后重构你认为需要额外关注的部分。 所以不要放太多层。一个好方法是编写测试需求,这样可以决定并完成一些测试,这将让你了解需要多少层。 - L.Trabacchin
你使用ORM吗?比如Entity Framework?我通常将这些存储设计为Entity Framework(数据库)和控制器/WebAPI之间的层,这样后者就不必了解数据源,如果你想要访问数据库,只需向存储添加一些方法即可。同时,通过替换存储为模拟对象也可以实现该功能。 - L.Trabacchin
是的,我使用实体框架。根据您的描述,这可能是一个服务层,它将数据库查询与业务逻辑封装在一起。 - Mozart
当然可以,那些数据可以来自任何来源,你只需要设计一个套接字。如果速度不够快,你可以为你的服务设计一个缓存,可以是数据库中的另一个较小的表,也可以是内存中的任何东西,只要能起作用就行。 - L.Trabacchin

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接