From 94e0b92780a08e409668c4883c101e7a96a0c5b5 Mon Sep 17 00:00:00 2001 From: "seonkyu.kim" Date: Tue, 10 Feb 2026 00:28:47 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20APNs/FCM=20=ED=82=A4=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EB=B0=8F=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - APNs 키 등록 API (POST /v1/in/service/{serviceCode}/apns) - FCM 키 등록 API (POST /v1/in/service/{serviceCode}/fcm) - 키 정보 조회 API (POST /v1/in/service/{serviceCode}/credentials) - AES-256 암호화로 민감 정보 저장 - 조회 시 메타 정보만 반환 (Private Key 미노출) Co-Authored-By: Claude Opus 4.5 --- SPMS.API/Controllers/ServiceController.cs | 48 +++++++ SPMS.API/appsettings.Development.json | 3 + SPMS.API/appsettings.json | 3 + .../DTOs/Service/ApnsCredentialsRequestDto.cs | 20 +++ .../DTOs/Service/CredentialsResponseDto.cs | 21 +++ .../DTOs/Service/FcmCredentialsRequestDto.cs | 9 ++ .../ICredentialEncryptionService.cs | 7 + .../Interfaces/IServiceManagementService.cs | 3 + .../Services/ServiceManagementService.cs | 136 +++++++++++++++++- SPMS.Domain/Common/ErrorCodes.cs | 4 + SPMS.Infrastructure/DependencyInjection.cs | 1 + .../Security/CredentialEncryptionService.cs | 69 +++++++++ 12 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 SPMS.Application/DTOs/Service/ApnsCredentialsRequestDto.cs create mode 100644 SPMS.Application/DTOs/Service/CredentialsResponseDto.cs create mode 100644 SPMS.Application/DTOs/Service/FcmCredentialsRequestDto.cs create mode 100644 SPMS.Application/Interfaces/ICredentialEncryptionService.cs create mode 100644 SPMS.Infrastructure/Security/CredentialEncryptionService.cs diff --git a/SPMS.API/Controllers/ServiceController.cs b/SPMS.API/Controllers/ServiceController.cs index 7532449..78c1b64 100644 --- a/SPMS.API/Controllers/ServiceController.cs +++ b/SPMS.API/Controllers/ServiceController.cs @@ -77,4 +77,52 @@ public class ServiceController : ControllerBase var result = await _serviceManagementService.RefreshApiKeyAsync(serviceCode); return Ok(ApiResponse.Success(result)); } + + [HttpPost("{serviceCode}/apns")] + [SwaggerOperation( + Summary = "APNs 키 등록", + Description = "APNs 푸시 발송을 위한 인증 정보를 등록합니다. .p8 파일 내용, Key ID(10자리), Team ID(10자리), Bundle ID가 필요합니다.")] + [SwaggerResponse(200, "등록 성공", typeof(ApiResponse))] + [SwaggerResponse(400, "잘못된 요청 (유효하지 않은 키 형식)")] + [SwaggerResponse(401, "인증되지 않은 요청")] + [SwaggerResponse(403, "권한 없음")] + [SwaggerResponse(404, "서비스를 찾을 수 없음")] + public async Task RegisterApnsCredentialsAsync( + [FromRoute] string serviceCode, + [FromBody] ApnsCredentialsRequestDto request) + { + await _serviceManagementService.RegisterApnsCredentialsAsync(serviceCode, request); + return Ok(ApiResponse.Success()); + } + + [HttpPost("{serviceCode}/fcm")] + [SwaggerOperation( + Summary = "FCM 키 등록", + Description = "FCM 푸시 발송을 위한 Service Account JSON을 등록합니다. Firebase Console에서 다운로드한 service-account.json 내용이 필요합니다.")] + [SwaggerResponse(200, "등록 성공", typeof(ApiResponse))] + [SwaggerResponse(400, "잘못된 요청 (유효하지 않은 JSON 형식)")] + [SwaggerResponse(401, "인증되지 않은 요청")] + [SwaggerResponse(403, "권한 없음")] + [SwaggerResponse(404, "서비스를 찾을 수 없음")] + public async Task RegisterFcmCredentialsAsync( + [FromRoute] string serviceCode, + [FromBody] FcmCredentialsRequestDto request) + { + await _serviceManagementService.RegisterFcmCredentialsAsync(serviceCode, request); + return Ok(ApiResponse.Success()); + } + + [HttpPost("{serviceCode}/credentials")] + [SwaggerOperation( + Summary = "푸시 키 정보 조회", + Description = "서비스에 등록된 APNs/FCM 키의 메타 정보를 조회합니다. 민감 정보(Private Key)는 반환되지 않습니다.")] + [SwaggerResponse(200, "조회 성공", typeof(ApiResponse))] + [SwaggerResponse(401, "인증되지 않은 요청")] + [SwaggerResponse(403, "권한 없음")] + [SwaggerResponse(404, "서비스를 찾을 수 없음")] + public async Task GetCredentialsAsync([FromRoute] string serviceCode) + { + var result = await _serviceManagementService.GetCredentialsAsync(serviceCode); + return Ok(ApiResponse.Success(result)); + } } diff --git a/SPMS.API/appsettings.Development.json b/SPMS.API/appsettings.Development.json index e26f77c..81cfe36 100644 --- a/SPMS.API/appsettings.Development.json +++ b/SPMS.API/appsettings.Development.json @@ -17,6 +17,9 @@ "Password": "", "VirtualHost": "dev" }, + "CredentialEncryption": { + "Key": "" + }, "Serilog": { "MinimumLevel": { "Default": "Debug", diff --git a/SPMS.API/appsettings.json b/SPMS.API/appsettings.json index 4af3f63..e049e6f 100644 --- a/SPMS.API/appsettings.json +++ b/SPMS.API/appsettings.json @@ -17,6 +17,9 @@ "Password": "", "VirtualHost": "/" }, + "CredentialEncryption": { + "Key": "" + }, "Serilog": { "Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"], "MinimumLevel": { diff --git a/SPMS.Application/DTOs/Service/ApnsCredentialsRequestDto.cs b/SPMS.Application/DTOs/Service/ApnsCredentialsRequestDto.cs new file mode 100644 index 0000000..72224bd --- /dev/null +++ b/SPMS.Application/DTOs/Service/ApnsCredentialsRequestDto.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace SPMS.Application.DTOs.Service; + +public class ApnsCredentialsRequestDto +{ + [Required(ErrorMessage = "Bundle ID는 필수입니다.")] + public string BundleId { get; set; } = string.Empty; + + [Required(ErrorMessage = "Key ID는 필수입니다.")] + [StringLength(10, MinimumLength = 10, ErrorMessage = "Key ID는 10자리여야 합니다.")] + public string KeyId { get; set; } = string.Empty; + + [Required(ErrorMessage = "Team ID는 필수입니다.")] + [StringLength(10, MinimumLength = 10, ErrorMessage = "Team ID는 10자리여야 합니다.")] + public string TeamId { get; set; } = string.Empty; + + [Required(ErrorMessage = "Private Key는 필수입니다.")] + public string PrivateKey { get; set; } = string.Empty; +} diff --git a/SPMS.Application/DTOs/Service/CredentialsResponseDto.cs b/SPMS.Application/DTOs/Service/CredentialsResponseDto.cs new file mode 100644 index 0000000..12ef26f --- /dev/null +++ b/SPMS.Application/DTOs/Service/CredentialsResponseDto.cs @@ -0,0 +1,21 @@ +namespace SPMS.Application.DTOs.Service; + +public class CredentialsResponseDto +{ + public ApnsCredentialsInfoDto? Apns { get; set; } + public FcmCredentialsInfoDto? Fcm { get; set; } +} + +public class ApnsCredentialsInfoDto +{ + public string BundleId { get; set; } = string.Empty; + public string KeyId { get; set; } = string.Empty; + public string TeamId { get; set; } = string.Empty; + public bool HasPrivateKey { get; set; } +} + +public class FcmCredentialsInfoDto +{ + public string? ProjectId { get; set; } + public bool HasCredentials { get; set; } +} diff --git a/SPMS.Application/DTOs/Service/FcmCredentialsRequestDto.cs b/SPMS.Application/DTOs/Service/FcmCredentialsRequestDto.cs new file mode 100644 index 0000000..27dd5b0 --- /dev/null +++ b/SPMS.Application/DTOs/Service/FcmCredentialsRequestDto.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace SPMS.Application.DTOs.Service; + +public class FcmCredentialsRequestDto +{ + [Required(ErrorMessage = "Service Account JSON은 필수입니다.")] + public string ServiceAccountJson { get; set; } = string.Empty; +} diff --git a/SPMS.Application/Interfaces/ICredentialEncryptionService.cs b/SPMS.Application/Interfaces/ICredentialEncryptionService.cs new file mode 100644 index 0000000..ad691a3 --- /dev/null +++ b/SPMS.Application/Interfaces/ICredentialEncryptionService.cs @@ -0,0 +1,7 @@ +namespace SPMS.Application.Interfaces; + +public interface ICredentialEncryptionService +{ + string Encrypt(string plainText); + string Decrypt(string encryptedText); +} diff --git a/SPMS.Application/Interfaces/IServiceManagementService.cs b/SPMS.Application/Interfaces/IServiceManagementService.cs index f7c008a..8715a71 100644 --- a/SPMS.Application/Interfaces/IServiceManagementService.cs +++ b/SPMS.Application/Interfaces/IServiceManagementService.cs @@ -8,4 +8,7 @@ public interface IServiceManagementService Task GetByServiceCodeAsync(string serviceCode); Task ChangeStatusAsync(string serviceCode, ChangeServiceStatusRequestDto request); Task RefreshApiKeyAsync(string serviceCode); + Task RegisterApnsCredentialsAsync(string serviceCode, ApnsCredentialsRequestDto request); + Task RegisterFcmCredentialsAsync(string serviceCode, FcmCredentialsRequestDto request); + Task GetCredentialsAsync(string serviceCode); } diff --git a/SPMS.Application/Services/ServiceManagementService.cs b/SPMS.Application/Services/ServiceManagementService.cs index 28d43ff..0a20e49 100644 --- a/SPMS.Application/Services/ServiceManagementService.cs +++ b/SPMS.Application/Services/ServiceManagementService.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using System.Security.Cryptography; +using System.Text.Json; using SPMS.Application.DTOs.Service; using SPMS.Application.Interfaces; using SPMS.Domain.Common; @@ -14,11 +15,16 @@ public class ServiceManagementService : IServiceManagementService { private readonly IServiceRepository _serviceRepository; private readonly IUnitOfWork _unitOfWork; + private readonly ICredentialEncryptionService _credentialEncryptionService; - public ServiceManagementService(IServiceRepository serviceRepository, IUnitOfWork unitOfWork) + public ServiceManagementService( + IServiceRepository serviceRepository, + IUnitOfWork unitOfWork, + ICredentialEncryptionService credentialEncryptionService) { _serviceRepository = serviceRepository; _unitOfWork = unitOfWork; + _credentialEncryptionService = credentialEncryptionService; } public async Task GetListAsync(ServiceListRequestDto request) @@ -145,6 +151,134 @@ public class ServiceManagementService : IServiceManagementService }; } + public async Task RegisterApnsCredentialsAsync(string serviceCode, ApnsCredentialsRequestDto request) + { + var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode); + if (service is null) + { + throw new SpmsException( + ErrorCodes.NotFound, + "서비스를 찾을 수 없습니다.", + 404); + } + + // Validate .p8 private key format + if (!request.PrivateKey.Contains("BEGIN PRIVATE KEY") || !request.PrivateKey.Contains("END PRIVATE KEY")) + { + throw new SpmsException( + ErrorCodes.InvalidCredentials, + "유효하지 않은 APNs Private Key 형식입니다. .p8 파일 내용을 입력해주세요.", + 400); + } + + // Encrypt and store + service.ApnsBundleId = request.BundleId; + service.ApnsKeyId = request.KeyId; + service.ApnsTeamId = request.TeamId; + service.ApnsPrivateKey = _credentialEncryptionService.Encrypt(request.PrivateKey); + service.UpdatedAt = DateTime.UtcNow; + + _serviceRepository.Update(service); + await _unitOfWork.SaveChangesAsync(); + } + + public async Task RegisterFcmCredentialsAsync(string serviceCode, FcmCredentialsRequestDto request) + { + var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode); + if (service is null) + { + throw new SpmsException( + ErrorCodes.NotFound, + "서비스를 찾을 수 없습니다.", + 404); + } + + // Validate JSON format and required fields + try + { + var jsonDoc = JsonDocument.Parse(request.ServiceAccountJson); + var root = jsonDoc.RootElement; + + if (!root.TryGetProperty("project_id", out _) || + !root.TryGetProperty("private_key", out _) || + !root.TryGetProperty("client_email", out _)) + { + throw new SpmsException( + ErrorCodes.InvalidCredentials, + "FCM Service Account JSON에 필수 필드(project_id, private_key, client_email)가 없습니다.", + 400); + } + } + catch (JsonException) + { + throw new SpmsException( + ErrorCodes.InvalidCredentials, + "유효하지 않은 JSON 형식입니다.", + 400); + } + + // Encrypt and store + service.FcmCredentials = _credentialEncryptionService.Encrypt(request.ServiceAccountJson); + service.UpdatedAt = DateTime.UtcNow; + + _serviceRepository.Update(service); + await _unitOfWork.SaveChangesAsync(); + } + + public async Task GetCredentialsAsync(string serviceCode) + { + var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode); + if (service is null) + { + throw new SpmsException( + ErrorCodes.NotFound, + "서비스를 찾을 수 없습니다.", + 404); + } + + var response = new CredentialsResponseDto(); + + // APNs info (meta only, no private key) + if (!string.IsNullOrEmpty(service.ApnsKeyId)) + { + response.Apns = new ApnsCredentialsInfoDto + { + BundleId = service.ApnsBundleId ?? string.Empty, + KeyId = service.ApnsKeyId ?? string.Empty, + TeamId = service.ApnsTeamId ?? string.Empty, + HasPrivateKey = !string.IsNullOrEmpty(service.ApnsPrivateKey) + }; + } + + // FCM info (project_id only, no private key) + if (!string.IsNullOrEmpty(service.FcmCredentials)) + { + var fcmJson = _credentialEncryptionService.Decrypt(service.FcmCredentials); + string? projectId = null; + + try + { + var jsonDoc = JsonDocument.Parse(fcmJson); + if (jsonDoc.RootElement.TryGetProperty("project_id", out var projectIdElement)) + { + projectId = projectIdElement.GetString(); + } + } + catch + { + // Ignore parsing errors + } + + response.Fcm = new FcmCredentialsInfoDto + { + ProjectId = projectId, + HasCredentials = true + }; + } + + return response; + } + private static ServiceSummaryDto MapToSummaryDto(Service service) { return new ServiceSummaryDto diff --git a/SPMS.Domain/Common/ErrorCodes.cs b/SPMS.Domain/Common/ErrorCodes.cs index 7bc7034..82373d8 100644 --- a/SPMS.Domain/Common/ErrorCodes.cs +++ b/SPMS.Domain/Common/ErrorCodes.cs @@ -28,6 +28,10 @@ public static class ErrorCodes public const string PasswordValidationFailed = "121"; public const string ResetTokenError = "122"; + // === Service (3) === + public const string DecryptionFailed = "131"; + public const string InvalidCredentials = "132"; + // === Push (6) === public const string PushSendFailed = "161"; public const string PushStateChangeNotAllowed = "162"; diff --git a/SPMS.Infrastructure/DependencyInjection.cs b/SPMS.Infrastructure/DependencyInjection.cs index cda4e95..b3d8291 100644 --- a/SPMS.Infrastructure/DependencyInjection.cs +++ b/SPMS.Infrastructure/DependencyInjection.cs @@ -30,6 +30,7 @@ public static class DependencyInjection // External Services services.AddScoped(); services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/SPMS.Infrastructure/Security/CredentialEncryptionService.cs b/SPMS.Infrastructure/Security/CredentialEncryptionService.cs new file mode 100644 index 0000000..fc3cc30 --- /dev/null +++ b/SPMS.Infrastructure/Security/CredentialEncryptionService.cs @@ -0,0 +1,69 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Configuration; +using SPMS.Application.Interfaces; +using SPMS.Domain.Common; +using SPMS.Domain.Exceptions; + +namespace SPMS.Infrastructure.Security; + +public class CredentialEncryptionService : ICredentialEncryptionService +{ + private readonly byte[] _key; + + public CredentialEncryptionService(IConfiguration configuration) + { + var keyBase64 = configuration["CredentialEncryption:Key"]; + if (string.IsNullOrEmpty(keyBase64)) + { + throw new InvalidOperationException("CredentialEncryption:Key is not configured."); + } + + _key = Convert.FromBase64String(keyBase64); + if (_key.Length != 32) + { + throw new InvalidOperationException("CredentialEncryption:Key must be 32 bytes (256 bits)."); + } + } + + public string Encrypt(string plainText) + { + if (string.IsNullOrEmpty(plainText)) + return string.Empty; + + var iv = AesEncryption.GenerateIv(); + var plainBytes = Encoding.UTF8.GetBytes(plainText); + var encryptedBytes = AesEncryption.Encrypt(plainBytes, _key, iv); + + var result = new byte[iv.Length + encryptedBytes.Length]; + Buffer.BlockCopy(iv, 0, result, 0, iv.Length); + Buffer.BlockCopy(encryptedBytes, 0, result, iv.Length, encryptedBytes.Length); + + return Convert.ToBase64String(result); + } + + public string Decrypt(string encryptedText) + { + if (string.IsNullOrEmpty(encryptedText)) + return string.Empty; + + try + { + var encryptedData = Convert.FromBase64String(encryptedText); + if (encryptedData.Length < 16) + { + throw new SpmsException(ErrorCodes.DecryptionFailed, "Invalid encrypted data.", 500); + } + + var iv = encryptedData[..16]; + var ciphertext = encryptedData[16..]; + + var decryptedBytes = AesEncryption.Decrypt(ciphertext, _key, iv); + return Encoding.UTF8.GetString(decryptedBytes); + } + catch (CryptographicException) + { + throw new SpmsException(ErrorCodes.DecryptionFailed, "Failed to decrypt credentials.", 500); + } + } +}