feat: APNs/FCM 키 등록 및 조회 API 구현 (#48)
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
3b4b1873a3
commit
94e0b92780
|
|
@ -77,4 +77,52 @@ public class ServiceController : ControllerBase
|
|||
var result = await _serviceManagementService.RefreshApiKeyAsync(serviceCode);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@
|
|||
"Password": "",
|
||||
"VirtualHost": "dev"
|
||||
},
|
||||
"CredentialEncryption": {
|
||||
"Key": ""
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Debug",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@
|
|||
"Password": "",
|
||||
"VirtualHost": "/"
|
||||
},
|
||||
"CredentialEncryption": {
|
||||
"Key": ""
|
||||
},
|
||||
"Serilog": {
|
||||
"Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File"],
|
||||
"MinimumLevel": {
|
||||
|
|
|
|||
20
SPMS.Application/DTOs/Service/ApnsCredentialsRequestDto.cs
Normal file
20
SPMS.Application/DTOs/Service/ApnsCredentialsRequestDto.cs
Normal 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;
|
||||
}
|
||||
21
SPMS.Application/DTOs/Service/CredentialsResponseDto.cs
Normal file
21
SPMS.Application/DTOs/Service/CredentialsResponseDto.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace SPMS.Application.Interfaces;
|
||||
|
||||
public interface ICredentialEncryptionService
|
||||
{
|
||||
string Encrypt(string plainText);
|
||||
string Decrypt(string encryptedText);
|
||||
}
|
||||
|
|
@ -8,4 +8,7 @@ public interface IServiceManagementService
|
|||
Task<ServiceResponseDto> GetByServiceCodeAsync(string serviceCode);
|
||||
Task<ServiceResponseDto> ChangeStatusAsync(string serviceCode, ChangeServiceStatusRequestDto request);
|
||||
Task<ApiKeyRefreshResponseDto> RefreshApiKeyAsync(string serviceCode);
|
||||
Task RegisterApnsCredentialsAsync(string serviceCode, ApnsCredentialsRequestDto request);
|
||||
Task RegisterFcmCredentialsAsync(string serviceCode, FcmCredentialsRequestDto request);
|
||||
Task<CredentialsResponseDto> GetCredentialsAsync(string serviceCode);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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)
|
||||
{
|
||||
return new ServiceSummaryDto
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ public static class DependencyInjection
|
|||
// External Services
|
||||
services.AddScoped<IJwtService, JwtService>();
|
||||
services.AddSingleton<IE2EEService, E2EEService>();
|
||||
services.AddSingleton<ICredentialEncryptionService, CredentialEncryptionService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
|
|
|||
69
SPMS.Infrastructure/Security/CredentialEncryptionService.cs
Normal file
69
SPMS.Infrastructure/Security/CredentialEncryptionService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user