|
|
@@ -0,0 +1,128 @@
|
|
|
+using SharedKernel.Constants;
|
|
|
+
|
|
|
+namespace Admin.Middlewares;
|
|
|
+
|
|
|
+public class MenuAuthorizationMiddleware(RequestDelegate next)
|
|
|
+{
|
|
|
+ // 메뉴 경로 → 허용 역할 매핑 (앱 시작 시 1회 빌드)
|
|
|
+ private static readonly Lazy<List<(string Path, List<string> Roles)>> MenuRoleMap = new(() =>
|
|
|
+ {
|
|
|
+ var result = new List<(string Path, List<string> Roles)>();
|
|
|
+ var menus = Menus.GetMenus();
|
|
|
+ CollectMenuRoles(menus, result);
|
|
|
+
|
|
|
+ // 긴 경로 우선 매칭되도록 내림차순 정렬
|
|
|
+ result.Sort((a, b) => b.Path.Length.CompareTo(a.Path.Length));
|
|
|
+
|
|
|
+ return result;
|
|
|
+ });
|
|
|
+
|
|
|
+ public async Task InvokeAsync(HttpContext context)
|
|
|
+ {
|
|
|
+ // 인증되지 않은 사용자는 Identity가 처리 (로그인 페이지로 이동)
|
|
|
+ if (context.User.Identity is not { IsAuthenticated: true })
|
|
|
+ {
|
|
|
+ await next(context);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ var path = context.Request.Path.Value ?? "";
|
|
|
+
|
|
|
+ // 정적 파일, Identity, Error 페이지는 권한 체크 제외
|
|
|
+ if (ShouldSkip(path))
|
|
|
+ {
|
|
|
+ await next(context);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 메뉴에 등록된 경로인지 확인
|
|
|
+ var matchedRoles = FindMatchingRoles(path);
|
|
|
+
|
|
|
+ if (matchedRoles != null)
|
|
|
+ {
|
|
|
+ // 역할 중 하나라도 가지고 있으면 허용
|
|
|
+ var hasAccess = matchedRoles.Any(role => context.User.IsInRole(role));
|
|
|
+
|
|
|
+ if (!hasAccess)
|
|
|
+ {
|
|
|
+ context.Response.StatusCode = 403;
|
|
|
+ context.Response.Redirect("/Identity/Account/AccessDenied");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ await next(context);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static List<string>? FindMatchingRoles(string path)
|
|
|
+ {
|
|
|
+ var trimmedPath = path.TrimEnd('/');
|
|
|
+
|
|
|
+ if (string.IsNullOrEmpty(trimmedPath))
|
|
|
+ {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ foreach (var (menuPath, roles) in MenuRoleMap.Value)
|
|
|
+ {
|
|
|
+ if (trimmedPath.Equals(menuPath, StringComparison.OrdinalIgnoreCase) || trimmedPath.StartsWith(menuPath + "/", StringComparison.OrdinalIgnoreCase))
|
|
|
+ {
|
|
|
+ return roles;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 권한 체크 제외 경로
|
|
|
+ private static readonly string[] SkipPrefixes =
|
|
|
+ [
|
|
|
+ "/Identity/",
|
|
|
+ "/lib/",
|
|
|
+ "/css/",
|
|
|
+ "/js/",
|
|
|
+ "/images/",
|
|
|
+ "/favicon",
|
|
|
+ "/_framework",
|
|
|
+ "/_blazor",
|
|
|
+ "/.well-known/",
|
|
|
+ "/Error"
|
|
|
+ ];
|
|
|
+
|
|
|
+ private static bool ShouldSkip(string path)
|
|
|
+ {
|
|
|
+ // 확장자가 있으면 정적 파일로 간주
|
|
|
+ var lastSegment = path.AsSpan(path.LastIndexOf('/') + 1);
|
|
|
+ if (lastSegment.Contains('.'))
|
|
|
+ {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ foreach (var prefix in SkipPrefixes)
|
|
|
+ {
|
|
|
+ if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
|
|
+ {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 메뉴 트리에서 경로와 역할을 수집하는 재귀 메서드
|
|
|
+ private static void CollectMenuRoles(List<Menu> menus, List<(string Path, List<string> Roles)> result)
|
|
|
+ {
|
|
|
+ foreach (var menu in menus)
|
|
|
+ {
|
|
|
+ if (!string.IsNullOrEmpty(menu.Path) && menu.Roles is { Count: > 0 })
|
|
|
+ {
|
|
|
+ result.Add((menu.Path.TrimEnd('/'), menu.Roles));
|
|
|
+ }
|
|
|
+
|
|
|
+ if (menu.Children is { Count: > 0 })
|
|
|
+ {
|
|
|
+ CollectMenuRoles(menu.Children, result);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|