MenuAuthorizationMiddleware.cs 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. using SharedKernel.Constants;
  2. namespace Admin.Middlewares;
  3. public class MenuAuthorizationMiddleware(RequestDelegate next)
  4. {
  5. // 메뉴 경로 → 허용 역할 매핑 (앱 시작 시 1회 빌드)
  6. private static readonly Lazy<List<(string Path, List<string> Roles)>> MenuRoleMap = new(() =>
  7. {
  8. var result = new List<(string Path, List<string> Roles)>();
  9. var menus = Menus.GetMenus();
  10. CollectMenuRoles(menus, result);
  11. // 긴 경로 우선 매칭되도록 내림차순 정렬
  12. result.Sort((a, b) => b.Path.Length.CompareTo(a.Path.Length));
  13. return result;
  14. });
  15. public async Task InvokeAsync(HttpContext context)
  16. {
  17. // 인증되지 않은 사용자는 Identity가 처리 (로그인 페이지로 이동)
  18. if (context.User.Identity is not { IsAuthenticated: true })
  19. {
  20. await next(context);
  21. return;
  22. }
  23. var path = context.Request.Path.Value ?? "";
  24. // 정적 파일, Identity, Error 페이지는 권한 체크 제외
  25. if (ShouldSkip(path))
  26. {
  27. await next(context);
  28. return;
  29. }
  30. // 메뉴에 등록된 경로인지 확인
  31. var matchedRoles = FindMatchingRoles(path);
  32. if (matchedRoles != null)
  33. {
  34. // 역할 중 하나라도 가지고 있으면 허용
  35. var hasAccess = matchedRoles.Any(role => context.User.IsInRole(role));
  36. if (!hasAccess)
  37. {
  38. context.Response.StatusCode = 403;
  39. context.Response.Redirect("/Identity/Account/AccessDenied");
  40. return;
  41. }
  42. }
  43. await next(context);
  44. }
  45. private static List<string>? FindMatchingRoles(string path)
  46. {
  47. var trimmedPath = path.TrimEnd('/');
  48. if (string.IsNullOrEmpty(trimmedPath))
  49. {
  50. return null;
  51. }
  52. foreach (var (menuPath, roles) in MenuRoleMap.Value)
  53. {
  54. if (trimmedPath.Equals(menuPath, StringComparison.OrdinalIgnoreCase) || trimmedPath.StartsWith(menuPath + "/", StringComparison.OrdinalIgnoreCase))
  55. {
  56. return roles;
  57. }
  58. }
  59. return null;
  60. }
  61. // 권한 체크 제외 경로
  62. private static readonly string[] SkipPrefixes =
  63. [
  64. "/Identity/",
  65. "/lib/",
  66. "/css/",
  67. "/js/",
  68. "/images/",
  69. "/favicon",
  70. "/_framework",
  71. "/_blazor",
  72. "/.well-known/",
  73. "/Error"
  74. ];
  75. private static bool ShouldSkip(string path)
  76. {
  77. // 확장자가 있으면 정적 파일로 간주
  78. var lastSegment = path.AsSpan(path.LastIndexOf('/') + 1);
  79. if (lastSegment.Contains('.'))
  80. {
  81. return true;
  82. }
  83. foreach (var prefix in SkipPrefixes)
  84. {
  85. if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
  86. {
  87. return true;
  88. }
  89. }
  90. return false;
  91. }
  92. // 메뉴 트리에서 경로와 역할을 수집하는 재귀 메서드
  93. private static void CollectMenuRoles(List<Menu> menus, List<(string Path, List<string> Roles)> result)
  94. {
  95. foreach (var menu in menus)
  96. {
  97. if (!string.IsNullOrEmpty(menu.Path) && menu.Roles is { Count: > 0 })
  98. {
  99. result.Add((menu.Path.TrimEnd('/'), menu.Roles));
  100. }
  101. if (menu.Children is { Count: > 0 })
  102. {
  103. CollectMenuRoles(menu.Children, result);
  104. }
  105. }
  106. }
  107. }