자원 기반 인가

aspnetcore 는 인가 미들웨어가 인가를 처리한다는 사실은 잘 알려져 있습니다.

그런데, 한 번쯤 이런 생각 해보신 적 있지 않나요?

인가 미들웨어로 처리하기가 꽤 까다롭네?

정책(Policy) 기반 인가

정책 인가는 도메인 로직과 상관 없이 미리 설정된 허용 규칙을 기계적으로 적용하는 것입니다.

대표적인 것이 롤 기반 인증(Role Based Authorization, RBA)입니다.

  • admin role 은 모든 자원에 대해 CRUD 허용
  • editor role 은 U 만 허용
  • reader role 은 R 만 허용

aspnetcore 의 인가 미들웨어는 정책 기반 인가를 처리하는데 최적화되어 있습니다.

자원(Resource) 기반 인가

자원 기반 인가는 자원에 대한 권한을 자원으로부터 확인하는 것을 가리킵니다.

Post 는 생성한 사람만 삭제할 수 있음

"생성한 사람"이라는 요구 조건은 자원(Post)을 꺼내 보기 전에는 확인할 방법이 없어, 자원에 접근하기 전에 실행되는 정책 기반 인가로는 그 목표를 달성하기 어렵습니다.

뭐, 인가 미들웨어도 한번 꺼내보고, 엔드 포인트도 꺼내 보고 하면 되기는 합니다. ^^

그런 방법보다는 좀 더 우아한 방법에 대해 썰을 풀어 봅니다.

도메인 로직

도메인 로직에 "인가"의 의미를 포함시킬 수 있습니다.

class User 
{
   public int Id { get; set; }
   ICollection<Post> posts { get; } = [];
   public IEnumerable<Post> Posts => posts.AsEnumerable();

   public bool Remove(Post p) => posts.Remove(p);
}
class Post
{
   public int Id { get; set; }
   public User? Creater { get; set; }
}

Post 는 작성자인 User 를 통해 삭제하도록 설계되었데, 이 로직 자체가 DELETE POST 에 대한 인가 정책이라고 할 수 있습니다.

다시 말하면, 이 도메인 로직 자체가 "자원 기반 인가"의 구현입니다.

도메인 로직으로 구현한 인가

var posts= app.MapGroup("/posts");

posts.MapDelete("/{id}", async (int id,
   ClaimsPrincipal user, AppDbContext db) =>
   {
      var userId = user.GetUserId(); // extension
      var user = await db.Users
         .Include(x => x.Posts) 
         .FirstOrDefaultAsync(x => x.Id == userId);

      // null check

      var post = user.Posts.FirstOrDefault(x => x.Id == id);

      // null check
      
      if (user.Remove(post))
      {
         _ = await db.SaveChangesAsync();
         return Results.Ok();
      }
      throw new ReachedUnreachableException();
   });

이 코드는 재사용 가능한 도메인 로직으로 구현했다는 점에서 일관성이 있습니다.
또한 DDD 를 지원하도록 설계된 EF Core 와도 찰떡 궁합입니다.

그러나, 어플리케이션 레이어에 삭제하지도 않을 User.Posts를 모두 가져오는 단점이 있습니다.

전용 인가 로직

이러한 단점은 전용 인가 로직을 도입하여 줄일 수 있습니다.

posts.MapDelete("/{id}", async (int id,
   ClaimsPrincipal user, AppDbContext db) =>
   {
      var userId = user.GetUserId();

      var post = await db.Posts
         .Include(x => x.Creater)         
         .FirstOrDefaultAsync(x => x.Id == id);

      // ...

      #region 인가 로직
      var canDelete = post.Creater?.Id == userId;
      if (!canDelete)
         return Results.Forbid();
      #endregion

      db.Posts.Remove(post);
      _ = await db.SaveChangesAsync();
      return Results.Ok();
   });

"생성한 유저"라는 요구 사항은 여러 엔드 포인트에 쓰일 수 있으므로, 아래와 같이 별도로 빼 놓아 재사용성을 높여 봅니다.

static class ResourceAuthorizations
{
   public static bool CanBeUpdatedOrDeletedBy(this Post post,
      int userId) =>
      userId != default && post.Creater?.Id == userId;
}
     #region 인가 로직
      if (!post.CanBeUpdatedOrDeletedBy(userId))
         return Results.Forbid();
      #endregion

로직과 인가의 분리

재사용 가능한 인가 로직을 만들었다고 맘 놓기는 이릅니다.
앞서 코드는 인가와 어플리케이션 로직이 섞여 있는 문제가 있기 때문입니다.

이렇게 관심사가 분리되지 못한 코드는 확장성과 재사용성에 한계를 쉽게 드러냅니다.
예를 들어, 프로젝트가 아래와 같이 성장한 경우라면,

class User 
{
   public int Id { get; set; }   
   public ICollection<Writer> WriterJobs { get; } = [];
}
class Writer
{
   public int Id { get; set; }
   public User? User { get; set; }
   private ICollection<Post> posts { get; } = [];
   public IEnumerable<Post> Posts => posts.AsEnumerable();

   public bool Remove(Post p) => posts.Remove(p);
}
class Post
{
   public int Id { get; set; }
   public Writer? Creater { get; set; }
}

변경된 코드에 맞게 새로운 인가 로직을 작성하고, 기존의 코드에서 아래의 코드를 전부 찾아 수정해야 합니다.

     #region 인가 로직
      if (post.CanBeUpdatedOrDeletedBy(userId))
         return Results.Forbid();
     #endregion

다음에 또 변경되면 또 다시 같은 일을 반복하겠죠? ^^

그냥 정성 들인 스파게티에 불과하다는 의미입니다.

스파게티로 전락한 이유는 관심사가 분리되지 않아서 입니다.

IAuthorizationHandler<TRequirement, TResouce>

다행스럽게도 닷넷에는 자원 기반 인증을 지원하는 인프라가 잘 구비되어 있습니다.

  • IAuthorizationRequirement
    인가 요구 사항을 나타내는 마커 인터페이스입니다.

  • AuthorizationHandler<TRequirement, TResource>
    주어진 자원에 대한 요구 사항이 충족되는 지 확인하는 핸들러입니다.

  • IAuthorizationService
    등록된 인가 핸들러를 호출합니다.

기존 코드용

우선 변경전 코드를 위한 자원 기반 인가 코드입니다.

class CreaterRequirement : IAuthorizaitonRequirement;
// Singleton 으로 의존성 주입

class CreaterRequirementHandler : 
   AuthorizationHandler<CreaterRequirement, Post>
{
   protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        CreaterRequirement requirement,
        Post post)
    {
        var userId = context.User.GetUserId();

        if (post.Creater?.Id == userId)
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}
     #region 인가 로직
     // IAuthorizeService authzService 주입
     var result = authzSerivce.AuthorizeAsync(user, post, new CreaterRequirement());
     if (!result.Succeeded)
         return Results.Forbid();
     #endregion

변경 코드 용

변경되었을 때 변화입니다.

우선 사전 작업으로 인증 티켓의 클레임에,

// ext. ClaimsPrincipal.GetUserId() 가 읽음.
"userId" : "nn" 

이 아래의 클레임을 추가합니다.

// ext. ClaimsPrincipal.GetWriterIds() 가 읽음.
"writerId" : "pp"
"wirterId" : "kk"

추가된 클레임에 기반한 핸들러입니다.

// 새로운 인증 핸들러
// 동일한 요구 사항, 동일한 자원

class WriterRequirementHandler : 
   AuthorizationHandler<CreaterRequirement, Post>
{
   protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        CreaterRequirement requirement,
        Post post)
    {
        var writerIdArray = context.User.GetWriterIds(); // extension 

        if (writerArray.Contains(post.Creater?.Id ?? 0))
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

보시다시피, 기존 코드 수정 없이, 인가 핸들러만 새로 정의해서 교체할 뿐입니다.
물론 인가 요구 조건 자체에 큰 변화가 있다면 광폭 수정이 필요하다는 점은 동일합니다.

마치며

정리하자면, 자원을 열어 봐야 결정할 수 있는 인가는 정책 기반 인가로 처리하는 것이 대부분 불가능하거나 비효율적입니다.

또한, 인가 코드와 로직 코드가 쉽게 섞여 유지 보수성이 좋지 못한 코드가 양산되기도 합니다.

var canDelete = post.Creater?.Id == userId;
if (!canDelete) return Results.Forbid();

심지어 위 코드가 인가를 수행한다는 인식 자체를 못하기도 하는데, 뭐 하나라도 바뀌면 수정 범위가 도대체 줄 지 않는 경험으로 이어지곤 합니다.

정책 기반 인가와 자원 기반 인가의 특성이 다르다는 점과 자원 기반 인가 구현 시 주의점을 이해한다면, Future-proof 하고 Token-saving 하는 코드 작성에 도움이 될 것입니다.

6 Likes

코드 비교

원글의 결론은 인가와 로직의 분리인데, 예제 코드가 그 의도를 충분히 반영하지 못했던 것 같아 코드 비교를 추가합니다.

우선, 시스템의 프로세스를 나타내는 유스케이스가 아래와 같이 정의되어 있음을 가정합니다.

public delegate Task<Result> DeletePostHandler(
   DeletePost request, CancellationToken token);

시스템 유스 케이스 입력

개선 전

인가와 관련된 값이 섞여 있습니다.

public record DeletePost(int Id, int CreaterId) : IRequest

개선 후

순수 로직에 관한 값만 요구합니다.

public record DeletePost(int Id) : IRequest

계약 이행

입력 값에 따라 구현 코드도 자연스럽게 달라집니다.

개선 전

static class PostCases
{
   // DI 등록 코드 생략

   public static DeletePostHandler DeletePostHandler(
      AppDbContext db) => async (rq, ct) =>
   {
      var post = await db.Posts
         .Include(x => x.Creater)
         .FirstOrDefaultAsync(x => x.Id == rq.Id, ct);

     // 인가
      if (post.Creater?.Id != rq.CreaterId)
         Result.NotAllowed();

      db.Posts.Remove(post);
      _ = db.Posts.SaveChangesAsync(ct);
      return Result.Success();
   }
}

개선 후

순수 로직만 처리합니다.

static class PostCases
{
   // DI 등록 코드 생략

   public static DeletePostHandler DeletePostHandler(
      AppDbContext db) => async (rq, ct) =>
   {
      var affected = await db.Posts
         .Where(x => x.Id == rq.Id)
         .ExecuteDeleteAsync(ct);

      return affected > 0 ? Result.Success()
         : Result.NotFound();
   }
}

참고로, 위 코드는 DB 에 대한 조작을 직설적으로 표현한 것입니다.

그런데, EF 라면, 아래와 같은 구현도 가능합니다.
(주석에 Select 로 표시된 부분 주목)

// ...
   public static DeletePostHandler DeletePostHandler(
      AppDbContext db) => async (rq, ct) =>
   {
      //  Select
      var post = await db.Posts.FindAsync([rq.Id], ct);

      if (post is null) return Result.NotFound();

      db.Post.Remove(post);
      _ = await db.SaveChangesAsync(ct);

      return Result.Success();
   }
}

프리젠테이션

개선 전

핸들러를 호출하는 것 외에 하는 일이 없습니다.
매우 깔끔합니다.

posts.MapDelete("/{id}", async (int id, 
   [FromQuery(Name = "cid")] int createrId,
   DeletePostHandler dpHandler,
   CancellationToken token) => 
      await dpHandler(new(id, createrId), token));

참고로, 이 엔드 포인트에 대한 Uri 입니다.

DELETE “…/posts/1234?cid=123”

시스템이 인가 정보를 요구하(하도록 설계했)기 때문에, Uri 에 그 정보가 노출됩니다.

public record DeletePost(int Id, int CreaterId) : IRequest

쿼리든, 해더든, 인가 정보는 클라이언트 측에 노출될 수 밖에 없습니다.

개선 후

인가 부분과 로직 부분을 분리할 수 있습니다.

posts.MapDelete("/{id}", async (int id, 
   IAuthorizationService authzService,
   ClaimsPrincipal user,
   AppDbContext db,
   DeletePostHandler dpHandler,
   CancellationToken token) =>
{
   // 인가 처리
   var post = await db.Posts
      .Include(x => x.Creater)
      .FirstOrDefaultAsync(id, token);

   if (post is null)
      return Results.NotFound();

   var authResult = await authzService
      .AuthorizeAsync(user, post, new CreaterRequirement());

   if (!authResult.Succeeded) 
      return Results.Forbid();
   
   // 로직 위임
   return Results.Ok(await dpHandler(new(id), token));
});

인가가 먼저 처리되고, 그 결과에 따라 로직이 호출됩니다.

참고로, 이 엔드 포인트에 대한 Uri 입니다. RESTful api의 전형입니다.

DELETE “…/posts/1234”

주목할 부분은 프리젠테이션과 로직에서 가각 Select 가 호출된다는 점입니다.

프리젠테이션에서 먼저 (Join으로) 호출되고,

   // 인가 처리
   var post = await db.Posts
      .Include(x => x.Creater) // Join
      .FirstOrDefaultAsync(id, token);

핸들러에서 한번 더 호출됩니다.

      //  Select
      var post = await db.Posts.FindAsync([rq.Id], ct);

이 두 번의 호출과 개선 전의 한번의 호출은 성능 차이가 있을까요?

      var post = await db.Posts
         .Include(x => x.Creater)
         .FirstOrDefaultAsync(x => x.Id == rq.Id, ct);

     // 인가
      if (post.Creater?.Id != rq.CreaterId)
         Result.NotAllowed();

EF 의 Change Tracker 가 앞 레이어에서 Select 한 객체를 추적하고 있기 때문에 큰 차이가 없다고 할 수 있습니다.

참로고, 이 점은 DbContext 를 Scopped 서비스로 등록하라는 이유를 잘 설명한다고 할 수 있습니다. 스코프 수명 주기는 트랙킹 중인 객체의 상태를 레이어 경계 넘어 전파할 수 있게 해주기 때문입니다.

마치며

마지막으로 이 썰이 해결해 주는 문제는 아래 영상에 잘 나와 있는 듯 합니다.

여기에, 영상에서 언급된 "해결이 되었지만 완전히 된 것 같지는 않다"가 말 해주는 기민성(Agility) 문제도 해결합니다.

3 Likes