From df8a8e2e5b99227cc2286161fe6abe407de3858b Mon Sep 17 00:00:00 2001 From: SEAN Date: Mon, 9 Feb 2026 17:25:19 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20X-Service-Code=20/=20X-API-KEY=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=8B=9D=EB=B3=84=20=EB=AF=B8?= =?UTF-8?q?=EB=93=A4=EC=9B=A8=EC=96=B4=20=EA=B5=AC=ED=98=84=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ServiceRepository: IServiceRepository 구현 (GetByServiceCode, GetByApiKey) - ServiceCodeMiddleware: X-Service-Code 헤더 검증, DB 조회, 서비스 상태 확인 - ApiKeyMiddleware: /v1/in/device/* 경로 X-API-KEY 검증 - ApplicationBuilderExtensions: 미들웨어 파이프라인 12~13번 등록 - DependencyInjection: IServiceRepository DI 등록 Closes #32 --- .../ApplicationBuilderExtensions.cs | 8 +-- SPMS.API/Middlewares/ApiKeyMiddleware.cs | 49 ++++++++++++++++ SPMS.API/Middlewares/ServiceCodeMiddleware.cs | 56 +++++++++++++++++++ SPMS.Infrastructure/DependencyInjection.cs | 1 + .../Repositories/ServiceRepository.cs | 25 +++++++++ 5 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 SPMS.API/Middlewares/ApiKeyMiddleware.cs create mode 100644 SPMS.API/Middlewares/ServiceCodeMiddleware.cs create mode 100644 SPMS.Infrastructure/Persistence/Repositories/ServiceRepository.cs diff --git a/SPMS.API/Extensions/ApplicationBuilderExtensions.cs b/SPMS.API/Extensions/ApplicationBuilderExtensions.cs index 09a50a3..0d5f0ae 100644 --- a/SPMS.API/Extensions/ApplicationBuilderExtensions.cs +++ b/SPMS.API/Extensions/ApplicationBuilderExtensions.cs @@ -56,11 +56,11 @@ public static class ApplicationBuilderExtensions // -- 11. 역할 인가 -- app.UseAuthorization(); - // -- 12. X-Service-Code 서비스 식별 (미구현 — Issue #13) -- - // app.UseMiddleware(); + // -- 12. X-Service-Code 서비스 식별 -- + app.UseMiddleware(); - // -- 13. X-API-KEY 검증 (미구현 — Issue #13) -- - // app.UseMiddleware(); + // -- 13. X-API-KEY 검증 (SDK/디바이스 엔드포인트용) -- + app.UseMiddleware(); // -- 14. X-SPMS-TEST 샌드박스 모드 (미구현 — Issue #14) -- // app.UseMiddleware(); diff --git a/SPMS.API/Middlewares/ApiKeyMiddleware.cs b/SPMS.API/Middlewares/ApiKeyMiddleware.cs new file mode 100644 index 0000000..e1a9143 --- /dev/null +++ b/SPMS.API/Middlewares/ApiKeyMiddleware.cs @@ -0,0 +1,49 @@ +using SPMS.Domain.Common; +using SPMS.Domain.Interfaces; + +namespace SPMS.API.Middlewares; + +public class ApiKeyMiddleware +{ + private readonly RequestDelegate _next; + + public ApiKeyMiddleware(RequestDelegate next) => _next = next; + + public async Task InvokeAsync(HttpContext context, IServiceRepository serviceRepository) + { + if (!RequiresApiKey(context.Request.Path)) + { + await _next(context); + return; + } + + if (!context.Request.Headers.TryGetValue("X-API-KEY", out var apiKey) || + string.IsNullOrWhiteSpace(apiKey)) + { + context.Response.StatusCode = 401; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync( + ApiResponse.Fail(ErrorCodes.Unauthorized, "API Key가 필요합니다.")); + return; + } + + var service = await serviceRepository.GetByApiKeyAsync(apiKey!); + if (service == null) + { + context.Response.StatusCode = 403; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync( + ApiResponse.Fail(ErrorCodes.Unauthorized, "유효하지 않은 API Key입니다.")); + return; + } + + context.Items["Service"] = service; + context.Items["ServiceId"] = service.Id; + await _next(context); + } + + private static bool RequiresApiKey(PathString path) + { + return path.StartsWithSegments("/v1/in/device"); + } +} diff --git a/SPMS.API/Middlewares/ServiceCodeMiddleware.cs b/SPMS.API/Middlewares/ServiceCodeMiddleware.cs new file mode 100644 index 0000000..241df27 --- /dev/null +++ b/SPMS.API/Middlewares/ServiceCodeMiddleware.cs @@ -0,0 +1,56 @@ +using SPMS.Domain.Common; +using SPMS.Domain.Enums; +using SPMS.Domain.Interfaces; + +namespace SPMS.API.Middlewares; + +public class ServiceCodeMiddleware +{ + private readonly RequestDelegate _next; + + public ServiceCodeMiddleware(RequestDelegate next) => _next = next; + + public async Task InvokeAsync(HttpContext context, IServiceRepository serviceRepository) + { + if (context.Request.Path.StartsWithSegments("/v1/out") || + context.Request.Path.StartsWithSegments("/swagger") || + context.Request.Path.StartsWithSegments("/health")) + { + await _next(context); + return; + } + + if (!context.Request.Headers.TryGetValue("X-Service-Code", out var serviceCode) || + string.IsNullOrWhiteSpace(serviceCode)) + { + context.Response.StatusCode = 400; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync( + ApiResponse.Fail(ErrorCodes.BadRequest, "X-Service-Code 헤더가 필요합니다.")); + return; + } + + var service = await serviceRepository.GetByServiceCodeAsync(serviceCode!); + if (service == null) + { + context.Response.StatusCode = 404; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync( + ApiResponse.Fail(ErrorCodes.NotFound, "존재하지 않는 서비스입니다.")); + return; + } + + if (service.Status != ServiceStatus.Active) + { + context.Response.StatusCode = 503; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync( + ApiResponse.Fail(ErrorCodes.Unauthorized, "비활성 상태의 서비스입니다.")); + return; + } + + context.Items["Service"] = service; + context.Items["ServiceId"] = service.Id; + await _next(context); + } +} diff --git a/SPMS.Infrastructure/DependencyInjection.cs b/SPMS.Infrastructure/DependencyInjection.cs index 1fe5cb4..41bb640 100644 --- a/SPMS.Infrastructure/DependencyInjection.cs +++ b/SPMS.Infrastructure/DependencyInjection.cs @@ -24,6 +24,7 @@ public static class DependencyInjection // UnitOfWork & Repositories services.AddScoped(); services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); + services.AddScoped(); // External Services services.AddScoped(); diff --git a/SPMS.Infrastructure/Persistence/Repositories/ServiceRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/ServiceRepository.cs new file mode 100644 index 0000000..d346ba9 --- /dev/null +++ b/SPMS.Infrastructure/Persistence/Repositories/ServiceRepository.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using SPMS.Domain.Entities; +using SPMS.Domain.Enums; +using SPMS.Domain.Interfaces; + +namespace SPMS.Infrastructure.Persistence.Repositories; + +public class ServiceRepository : Repository, IServiceRepository +{ + public ServiceRepository(AppDbContext context) : base(context) { } + + public async Task GetByServiceCodeAsync(string serviceCode) + => await _dbSet.FirstOrDefaultAsync(s => s.ServiceCode == serviceCode); + + public async Task GetByApiKeyAsync(string apiKey) + => await _dbSet.FirstOrDefaultAsync(s => s.ApiKey == apiKey); + + public async Task GetByIdWithIpsAsync(long id) + => await _dbSet + .Include(s => s.ServiceIps) + .FirstOrDefaultAsync(s => s.Id == id); + + public async Task> GetByStatusAsync(ServiceStatus status) + => await _dbSet.Where(s => s.Status == status).ToListAsync(); +} -- 2.45.1