feat: APNs/FCM 키 등록 및 조회 API 구현 (#48)
All checks were successful
SPMS_API/pipeline/head This commit looks good

Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/49
This commit is contained in:
김선규 2026-02-09 15:33:07 +00:00
commit e335f762bd
12 changed files with 323 additions and 1 deletions

View File

@ -77,4 +77,52 @@ public class ServiceController : ControllerBase
var result = await _serviceManagementService.RefreshApiKeyAsync(serviceCode); var result = await _serviceManagementService.RefreshApiKeyAsync(serviceCode);
return Ok(ApiResponse<ApiKeyRefreshResponseDto>.Success(result)); return Ok(ApiResponse<ApiKeyRefreshResponseDto>.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<IActionResult> 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<IActionResult> 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<CredentialsResponseDto>))]
[SwaggerResponse(401, "인증되지 않은 요청")]
[SwaggerResponse(403, "권한 없음")]
[SwaggerResponse(404, "서비스를 찾을 수 없음")]
public async Task<IActionResult> GetCredentialsAsync([FromRoute] string serviceCode)
{
var result = await _serviceManagementService.GetCredentialsAsync(serviceCode);
return Ok(ApiResponse<CredentialsResponseDto>.Success(result));
}
} }

View File

@ -17,6 +17,9 @@
"Password": "", "Password": "",
"VirtualHost": "dev" "VirtualHost": "dev"
}, },
"CredentialEncryption": {
"Key": ""
},
"Serilog": { "Serilog": {
"MinimumLevel": { "MinimumLevel": {
"Default": "Debug", "Default": "Debug",

View File

@ -17,6 +17,9 @@
"Password": "", "Password": "",
"VirtualHost": "/" "VirtualHost": "/"
}, },
"CredentialEncryption": {
"Key": ""
},
"Serilog": { "Serilog": {
"Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"], "Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"],
"MinimumLevel": { "MinimumLevel": {

View File

@ -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;
}

View File

@ -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; }
}

View File

@ -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;
}

View File

@ -0,0 +1,7 @@
namespace SPMS.Application.Interfaces;
public interface ICredentialEncryptionService
{
string Encrypt(string plainText);
string Decrypt(string encryptedText);
}

View File

@ -8,4 +8,7 @@ public interface IServiceManagementService
Task<ServiceResponseDto> GetByServiceCodeAsync(string serviceCode); Task<ServiceResponseDto> GetByServiceCodeAsync(string serviceCode);
Task<ServiceResponseDto> ChangeStatusAsync(string serviceCode, ChangeServiceStatusRequestDto request); Task<ServiceResponseDto> ChangeStatusAsync(string serviceCode, ChangeServiceStatusRequestDto request);
Task<ApiKeyRefreshResponseDto> RefreshApiKeyAsync(string serviceCode); Task<ApiKeyRefreshResponseDto> RefreshApiKeyAsync(string serviceCode);
Task RegisterApnsCredentialsAsync(string serviceCode, ApnsCredentialsRequestDto request);
Task RegisterFcmCredentialsAsync(string serviceCode, FcmCredentialsRequestDto request);
Task<CredentialsResponseDto> GetCredentialsAsync(string serviceCode);
} }

View File

@ -1,5 +1,6 @@
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json;
using SPMS.Application.DTOs.Service; using SPMS.Application.DTOs.Service;
using SPMS.Application.Interfaces; using SPMS.Application.Interfaces;
using SPMS.Domain.Common; using SPMS.Domain.Common;
@ -14,11 +15,16 @@ public class ServiceManagementService : IServiceManagementService
{ {
private readonly IServiceRepository _serviceRepository; private readonly IServiceRepository _serviceRepository;
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly ICredentialEncryptionService _credentialEncryptionService;
public ServiceManagementService(IServiceRepository serviceRepository, IUnitOfWork unitOfWork) public ServiceManagementService(
IServiceRepository serviceRepository,
IUnitOfWork unitOfWork,
ICredentialEncryptionService credentialEncryptionService)
{ {
_serviceRepository = serviceRepository; _serviceRepository = serviceRepository;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_credentialEncryptionService = credentialEncryptionService;
} }
public async Task<ServiceListResponseDto> GetListAsync(ServiceListRequestDto request) public async Task<ServiceListResponseDto> 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<CredentialsResponseDto> 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) private static ServiceSummaryDto MapToSummaryDto(Service service)
{ {
return new ServiceSummaryDto return new ServiceSummaryDto

View File

@ -28,6 +28,10 @@ public static class ErrorCodes
public const string PasswordValidationFailed = "121"; public const string PasswordValidationFailed = "121";
public const string ResetTokenError = "122"; public const string ResetTokenError = "122";
// === Service (3) ===
public const string DecryptionFailed = "131";
public const string InvalidCredentials = "132";
// === Push (6) === // === Push (6) ===
public const string PushSendFailed = "161"; public const string PushSendFailed = "161";
public const string PushStateChangeNotAllowed = "162"; public const string PushStateChangeNotAllowed = "162";

View File

@ -30,6 +30,7 @@ public static class DependencyInjection
// External Services // External Services
services.AddScoped<IJwtService, JwtService>(); services.AddScoped<IJwtService, JwtService>();
services.AddSingleton<IE2EEService, E2EEService>(); services.AddSingleton<IE2EEService, E2EEService>();
services.AddSingleton<ICredentialEncryptionService, CredentialEncryptionService>();
return services; return services;
} }

View File

@ -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);
}
}
}