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 하는 코드 작성에 도움이 될 것입니다.