improvement: APNs p12 인증서 지원 추가 (#214)
All checks were successful
SPMS_API/pipeline/head This commit looks good

Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/215
This commit is contained in:
김선규 2026-02-25 04:04:12 +00:00
commit e50f3f186c
10 changed files with 1372 additions and 33 deletions

View File

@ -36,7 +36,7 @@ public class ServiceController : ControllerBase
[HttpPost("register")] [HttpPost("register")]
[SwaggerOperation( [SwaggerOperation(
Summary = "서비스 통합 등록", Summary = "서비스 통합 등록",
Description = "서비스 생성과 플랫폼 자격증명 등록을 단일 호출로 완료합니다. FCM/APNs 자격증명은 선택사항이며, 제공 시 검증 실패하면 전체 롤백됩니다.")] Description = "서비스 생성과 플랫폼 자격증명 등록을 단일 호출로 완료합니다. FCM/APNs 자격증명은 선택사항이며, 제공 시 검증 실패하면 전체 롤백됩니다. APNs는 AuthType(p8/p12)에 따라 필수 필드가 달라집니다.")]
[SwaggerResponse(200, "등록 성공", typeof(ApiResponse<RegisterServiceResponseDto>))] [SwaggerResponse(200, "등록 성공", typeof(ApiResponse<RegisterServiceResponseDto>))]
[SwaggerResponse(400, "잘못된 요청")] [SwaggerResponse(400, "잘못된 요청")]
[SwaggerResponse(409, "서비스명 중복")] [SwaggerResponse(409, "서비스명 중복")]
@ -161,9 +161,9 @@ public class ServiceController : ControllerBase
[HttpPost("{serviceCode}/apns")] [HttpPost("{serviceCode}/apns")]
[SwaggerOperation( [SwaggerOperation(
Summary = "APNs 키 등록", Summary = "APNs 키 등록",
Description = "APNs 푸시 발송을 위한 인증 정보를 등록합니다. .p8 파일 내용, Key ID(10자리), Team ID(10자리), Bundle ID가 필요합니다.")] Description = "APNs 푸시 발송을 위한 인증 정보를 등록합니다. AuthType=p8: Key ID(10자리), Team ID(10자리), Private Key(.p8) 필수. AuthType=p12: CertificateBase64(Base64 인코딩된 .p12), CertPassword 필수. 타입 전환 시 이전 타입 필드는 자동 초기화됩니다.")]
[SwaggerResponse(200, "등록 성공", typeof(ApiResponse))] [SwaggerResponse(200, "등록 성공", typeof(ApiResponse))]
[SwaggerResponse(400, "잘못된 요청 (유효하지 않은 키 형식)")] [SwaggerResponse(400, "잘못된 요청 (유효하지 않은 키/인증서 형식)")]
[SwaggerResponse(401, "인증되지 않은 요청")] [SwaggerResponse(401, "인증되지 않은 요청")]
[SwaggerResponse(403, "권한 없음")] [SwaggerResponse(403, "권한 없음")]
[SwaggerResponse(404, "서비스를 찾을 수 없음")] [SwaggerResponse(404, "서비스를 찾을 수 없음")]

View File

@ -7,14 +7,21 @@ public class ApnsCredentialsRequestDto
[Required(ErrorMessage = "Bundle ID는 필수입니다.")] [Required(ErrorMessage = "Bundle ID는 필수입니다.")]
public string BundleId { get; set; } = string.Empty; public string BundleId { get; set; } = string.Empty;
[Required(ErrorMessage = "Key ID는 필수입니다.")] [Required(ErrorMessage = "인증 타입은 필수입니다.")]
[RegularExpression("^(p8|p12)$", ErrorMessage = "AuthType은 p8 또는 p12만 가능합니다.")]
public string AuthType { get; set; } = "p8";
// p8 필드 (AuthType=p8일 때 서비스에서 필수 검증)
[StringLength(10, MinimumLength = 10, ErrorMessage = "Key ID는 10자리여야 합니다.")] [StringLength(10, MinimumLength = 10, ErrorMessage = "Key ID는 10자리여야 합니다.")]
public string KeyId { get; set; } = string.Empty; public string? KeyId { get; set; }
[Required(ErrorMessage = "Team ID는 필수입니다.")]
[StringLength(10, MinimumLength = 10, ErrorMessage = "Team ID는 10자리여야 합니다.")] [StringLength(10, MinimumLength = 10, ErrorMessage = "Team ID는 10자리여야 합니다.")]
public string TeamId { get; set; } = string.Empty; public string? TeamId { get; set; }
[Required(ErrorMessage = "Private Key는 필수입니다.")] public string? PrivateKey { get; set; }
public string PrivateKey { get; set; } = string.Empty;
// p12 필드 (AuthType=p12일 때 서비스에서 필수 검증)
public string? CertificateBase64 { get; set; }
public string? CertPassword { get; set; }
} }

View File

@ -9,9 +9,14 @@ public class CredentialsResponseDto
public class ApnsCredentialsInfoDto public class ApnsCredentialsInfoDto
{ {
public string BundleId { get; set; } = string.Empty; public string BundleId { get; set; } = string.Empty;
public string KeyId { get; set; } = string.Empty; public string? AuthType { get; set; } // "p8" or "p12"
public string TeamId { get; set; } = string.Empty; // p8 메타
public string? KeyId { get; set; }
public string? TeamId { get; set; }
public bool HasPrivateKey { get; set; } public bool HasPrivateKey { get; set; }
// p12 메타
public bool HasCertificate { get; set; }
public DateTime? CertExpiresAt { get; set; }
} }
public class FcmCredentialsInfoDto public class FcmCredentialsInfoDto

View File

@ -33,14 +33,21 @@ public class ApnsCredentialDto
[Required(ErrorMessage = "Bundle ID는 필수입니다.")] [Required(ErrorMessage = "Bundle ID는 필수입니다.")]
public string BundleId { get; set; } = string.Empty; public string BundleId { get; set; } = string.Empty;
[Required(ErrorMessage = "Key ID는 필수입니다.")] [Required(ErrorMessage = "인증 타입은 필수입니다.")]
[RegularExpression("^(p8|p12)$", ErrorMessage = "AuthType은 p8 또는 p12만 가능합니다.")]
public string AuthType { get; set; } = "p8";
// p8 필드
[StringLength(10, MinimumLength = 10, ErrorMessage = "Key ID는 10자리여야 합니다.")] [StringLength(10, MinimumLength = 10, ErrorMessage = "Key ID는 10자리여야 합니다.")]
public string KeyId { get; set; } = string.Empty; public string? KeyId { get; set; }
[Required(ErrorMessage = "Team ID는 필수입니다.")]
[StringLength(10, MinimumLength = 10, ErrorMessage = "Team ID는 10자리여야 합니다.")] [StringLength(10, MinimumLength = 10, ErrorMessage = "Team ID는 10자리여야 합니다.")]
public string TeamId { get; set; } = string.Empty; public string? TeamId { get; set; }
[Required(ErrorMessage = "Private Key는 필수입니다.")] public string? PrivateKey { get; set; }
public string PrivateKey { get; set; } = string.Empty;
// p12 필드
public string? CertificateBase64 { get; set; }
public string? CertPassword { get; set; }
} }

View File

@ -1,5 +1,6 @@
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json; using System.Text.Json;
using SPMS.Application.DTOs.Service; using SPMS.Application.DTOs.Service;
using SPMS.Application.Interfaces; using SPMS.Application.Interfaces;
@ -347,13 +348,50 @@ public class ServiceManagementService : IServiceManagementService
404); 404);
} }
ValidateApnsCredentials(request.PrivateKey);
// Encrypt and store
service.ApnsBundleId = request.BundleId; service.ApnsBundleId = request.BundleId;
service.ApnsKeyId = request.KeyId; service.ApnsAuthType = request.AuthType;
service.ApnsTeamId = request.TeamId;
service.ApnsPrivateKey = _credentialEncryptionService.Encrypt(request.PrivateKey); if (request.AuthType == "p12")
{
// p12 필수 필드 검증
if (string.IsNullOrEmpty(request.CertificateBase64))
throw new SpmsException(ErrorCodes.BadRequest, "p12 인증서(CertificateBase64)는 필수입니다.", 400);
if (string.IsNullOrEmpty(request.CertPassword))
throw new SpmsException(ErrorCodes.BadRequest, "p12 비밀번호(CertPassword)는 필수입니다.", 400);
var expiresAt = ValidateApnsP12Credentials(request.CertificateBase64, request.CertPassword);
service.ApnsCertificate = _credentialEncryptionService.Encrypt(request.CertificateBase64);
service.ApnsCertPassword = _credentialEncryptionService.Encrypt(request.CertPassword);
service.ApnsCertExpiresAt = expiresAt;
// p8 필드 초기화
service.ApnsKeyId = null;
service.ApnsTeamId = null;
service.ApnsPrivateKey = null;
}
else
{
// p8 필수 필드 검증
if (string.IsNullOrEmpty(request.KeyId))
throw new SpmsException(ErrorCodes.BadRequest, "Key ID는 필수입니다.", 400);
if (string.IsNullOrEmpty(request.TeamId))
throw new SpmsException(ErrorCodes.BadRequest, "Team ID는 필수입니다.", 400);
if (string.IsNullOrEmpty(request.PrivateKey))
throw new SpmsException(ErrorCodes.BadRequest, "Private Key는 필수입니다.", 400);
ValidateApnsP8Credentials(request.PrivateKey);
service.ApnsKeyId = request.KeyId;
service.ApnsTeamId = request.TeamId;
service.ApnsPrivateKey = _credentialEncryptionService.Encrypt(request.PrivateKey);
// p12 필드 초기화
service.ApnsCertificate = null;
service.ApnsCertPassword = null;
service.ApnsCertExpiresAt = null;
}
service.UpdatedAt = DateTime.UtcNow; service.UpdatedAt = DateTime.UtcNow;
_serviceRepository.Update(service); _serviceRepository.Update(service);
@ -397,8 +435,30 @@ public class ServiceManagementService : IServiceManagementService
if (request.Fcm != null) if (request.Fcm != null)
ValidateFcmCredentials(request.Fcm.ServiceAccountJson); ValidateFcmCredentials(request.Fcm.ServiceAccountJson);
DateTime? apnsCertExpiresAt = null;
if (request.Apns != null) if (request.Apns != null)
ValidateApnsCredentials(request.Apns.PrivateKey); {
if (request.Apns.AuthType == "p12")
{
if (string.IsNullOrEmpty(request.Apns.CertificateBase64))
throw new SpmsException(ErrorCodes.BadRequest, "p12 인증서(CertificateBase64)는 필수입니다.", 400);
if (string.IsNullOrEmpty(request.Apns.CertPassword))
throw new SpmsException(ErrorCodes.BadRequest, "p12 비밀번호(CertPassword)는 필수입니다.", 400);
apnsCertExpiresAt = ValidateApnsP12Credentials(request.Apns.CertificateBase64, request.Apns.CertPassword);
}
else
{
if (string.IsNullOrEmpty(request.Apns.KeyId))
throw new SpmsException(ErrorCodes.BadRequest, "Key ID는 필수입니다.", 400);
if (string.IsNullOrEmpty(request.Apns.TeamId))
throw new SpmsException(ErrorCodes.BadRequest, "Team ID는 필수입니다.", 400);
if (string.IsNullOrEmpty(request.Apns.PrivateKey))
throw new SpmsException(ErrorCodes.BadRequest, "Private Key는 필수입니다.", 400);
ValidateApnsP8Credentials(request.Apns.PrivateKey);
}
}
// 3. 트랜잭션 시작 // 3. 트랜잭션 시작
using var transaction = await _unitOfWork.BeginTransactionAsync(); using var transaction = await _unitOfWork.BeginTransactionAsync();
@ -439,9 +499,20 @@ public class ServiceManagementService : IServiceManagementService
if (request.Apns != null) if (request.Apns != null)
{ {
service.ApnsBundleId = request.Apns.BundleId; service.ApnsBundleId = request.Apns.BundleId;
service.ApnsKeyId = request.Apns.KeyId; service.ApnsAuthType = request.Apns.AuthType;
service.ApnsTeamId = request.Apns.TeamId;
service.ApnsPrivateKey = _credentialEncryptionService.Encrypt(request.Apns.PrivateKey); if (request.Apns.AuthType == "p12")
{
service.ApnsCertificate = _credentialEncryptionService.Encrypt(request.Apns.CertificateBase64!);
service.ApnsCertPassword = _credentialEncryptionService.Encrypt(request.Apns.CertPassword!);
service.ApnsCertExpiresAt = apnsCertExpiresAt;
}
else
{
service.ApnsKeyId = request.Apns.KeyId;
service.ApnsTeamId = request.Apns.TeamId;
service.ApnsPrivateKey = _credentialEncryptionService.Encrypt(request.Apns.PrivateKey!);
}
} }
await _serviceRepository.AddAsync(service); await _serviceRepository.AddAsync(service);
@ -497,7 +568,7 @@ public class ServiceManagementService : IServiceManagementService
} }
} }
private static void ValidateApnsCredentials(string privateKey) private static void ValidateApnsP8Credentials(string privateKey)
{ {
if (!privateKey.Contains("BEGIN PRIVATE KEY") || !privateKey.Contains("END PRIVATE KEY")) if (!privateKey.Contains("BEGIN PRIVATE KEY") || !privateKey.Contains("END PRIVATE KEY"))
{ {
@ -508,6 +579,38 @@ public class ServiceManagementService : IServiceManagementService
} }
} }
/// <summary>
/// p12 인증서 Base64를 파싱하여 유효성 검증하고 만료일을 반환합니다.
/// </summary>
private static DateTime ValidateApnsP12Credentials(string certBase64, string certPassword)
{
byte[] certBytes;
try
{
certBytes = Convert.FromBase64String(certBase64);
}
catch (FormatException)
{
throw new SpmsException(
ErrorCodes.InvalidCredentials,
"유효하지 않은 Base64 형식입니다. p12 인증서를 Base64로 인코딩하여 전송해주세요.",
400);
}
try
{
using var cert = X509CertificateLoader.LoadPkcs12(certBytes, certPassword);
return cert.NotAfter;
}
catch (CryptographicException)
{
throw new SpmsException(
ErrorCodes.InvalidCredentials,
"p12 인증서를 읽을 수 없습니다. 인증서 파일 또는 비밀번호를 확인해주세요.",
400);
}
}
public async Task<CredentialsResponseDto> GetCredentialsAsync(string serviceCode) public async Task<CredentialsResponseDto> GetCredentialsAsync(string serviceCode)
{ {
var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode); var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode);
@ -522,14 +625,17 @@ public class ServiceManagementService : IServiceManagementService
var response = new CredentialsResponseDto(); var response = new CredentialsResponseDto();
// APNs info (meta only, no private key) // APNs info (meta only, no private key)
if (!string.IsNullOrEmpty(service.ApnsKeyId)) if (!string.IsNullOrEmpty(service.ApnsBundleId))
{ {
response.Apns = new ApnsCredentialsInfoDto response.Apns = new ApnsCredentialsInfoDto
{ {
BundleId = service.ApnsBundleId ?? string.Empty, BundleId = service.ApnsBundleId,
KeyId = service.ApnsKeyId ?? string.Empty, AuthType = service.ApnsAuthType,
TeamId = service.ApnsTeamId ?? string.Empty, KeyId = service.ApnsKeyId,
HasPrivateKey = !string.IsNullOrEmpty(service.ApnsPrivateKey) TeamId = service.ApnsTeamId,
HasPrivateKey = !string.IsNullOrEmpty(service.ApnsPrivateKey),
HasCertificate = !string.IsNullOrEmpty(service.ApnsCertificate),
CertExpiresAt = service.ApnsCertExpiresAt
}; };
} }

View File

@ -13,6 +13,10 @@ public class Service : BaseEntity
public string? ApnsKeyId { get; set; } public string? ApnsKeyId { get; set; }
public string? ApnsTeamId { get; set; } public string? ApnsTeamId { get; set; }
public string? ApnsPrivateKey { get; set; } public string? ApnsPrivateKey { get; set; }
public string? ApnsAuthType { get; set; } // "p8" or "p12"
public string? ApnsCertificate { get; set; } // p12 Base64 (암호화 저장)
public string? ApnsCertPassword { get; set; } // p12 비밀번호 (암호화 저장)
public DateTime? ApnsCertExpiresAt { get; set; } // p12 인증서 만료일
public string? FcmCredentials { get; set; } public string? FcmCredentials { get; set; }
public string? WebhookUrl { get; set; } public string? WebhookUrl { get; set; }
public string? WebhookEvents { get; set; } public string? WebhookEvents { get; set; }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,63 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SPMS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddApnsP12Support : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ApnsAuthType",
table: "Service",
type: "varchar(10)",
maxLength: 10,
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<DateTime>(
name: "ApnsCertExpiresAt",
table: "Service",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ApnsCertPassword",
table: "Service",
type: "text",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<string>(
name: "ApnsCertificate",
table: "Service",
type: "text",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ApnsAuthType",
table: "Service");
migrationBuilder.DropColumn(
name: "ApnsCertExpiresAt",
table: "Service");
migrationBuilder.DropColumn(
name: "ApnsCertPassword",
table: "Service");
migrationBuilder.DropColumn(
name: "ApnsCertificate",
table: "Service");
}
}
}

View File

@ -675,10 +675,23 @@ namespace SPMS.Infrastructure.Migrations
b.Property<DateTime>("ApiKeyCreatedAt") b.Property<DateTime>("ApiKeyCreatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
b.Property<string>("ApnsAuthType")
.HasMaxLength(10)
.HasColumnType("varchar(10)");
b.Property<string>("ApnsBundleId") b.Property<string>("ApnsBundleId")
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("varchar(100)"); .HasColumnType("varchar(100)");
b.Property<DateTime?>("ApnsCertExpiresAt")
.HasColumnType("datetime(6)");
b.Property<string>("ApnsCertPassword")
.HasColumnType("text");
b.Property<string>("ApnsCertificate")
.HasColumnType("text");
b.Property<string>("ApnsKeyId") b.Property<string>("ApnsKeyId")
.HasMaxLength(10) .HasMaxLength(10)
.HasColumnType("varchar(10)"); .HasColumnType("varchar(10)");

View File

@ -24,6 +24,10 @@ public class ServiceConfiguration : IEntityTypeConfiguration<Service>
builder.Property(e => e.ApnsKeyId).HasMaxLength(10); builder.Property(e => e.ApnsKeyId).HasMaxLength(10);
builder.Property(e => e.ApnsTeamId).HasMaxLength(10); builder.Property(e => e.ApnsTeamId).HasMaxLength(10);
builder.Property(e => e.ApnsPrivateKey).HasColumnType("text"); builder.Property(e => e.ApnsPrivateKey).HasColumnType("text");
builder.Property(e => e.ApnsAuthType).HasMaxLength(10);
builder.Property(e => e.ApnsCertificate).HasColumnType("text");
builder.Property(e => e.ApnsCertPassword).HasColumnType("text");
builder.Property(e => e.ApnsCertExpiresAt);
builder.Property(e => e.FcmCredentials).HasColumnType("text"); builder.Property(e => e.FcmCredentials).HasColumnType("text");
builder.Property(e => e.WebhookUrl).HasMaxLength(500); builder.Property(e => e.WebhookUrl).HasMaxLength(500);
builder.Property(e => e.Tags).HasColumnType("json"); builder.Property(e => e.Tags).HasColumnType("json");