improvement: APNs p12 인증서 지원 추가 (#214)
All checks were successful
SPMS_API/pipeline/head This commit looks good
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:
commit
e50f3f186c
|
|
@ -36,7 +36,7 @@ public class ServiceController : ControllerBase
|
|||
[HttpPost("register")]
|
||||
[SwaggerOperation(
|
||||
Summary = "서비스 통합 등록",
|
||||
Description = "서비스 생성과 플랫폼 자격증명 등록을 단일 호출로 완료합니다. FCM/APNs 자격증명은 선택사항이며, 제공 시 검증 실패하면 전체 롤백됩니다.")]
|
||||
Description = "서비스 생성과 플랫폼 자격증명 등록을 단일 호출로 완료합니다. FCM/APNs 자격증명은 선택사항이며, 제공 시 검증 실패하면 전체 롤백됩니다. APNs는 AuthType(p8/p12)에 따라 필수 필드가 달라집니다.")]
|
||||
[SwaggerResponse(200, "등록 성공", typeof(ApiResponse<RegisterServiceResponseDto>))]
|
||||
[SwaggerResponse(400, "잘못된 요청")]
|
||||
[SwaggerResponse(409, "서비스명 중복")]
|
||||
|
|
@ -161,9 +161,9 @@ public class ServiceController : ControllerBase
|
|||
[HttpPost("{serviceCode}/apns")]
|
||||
[SwaggerOperation(
|
||||
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(400, "잘못된 요청 (유효하지 않은 키 형식)")]
|
||||
[SwaggerResponse(400, "잘못된 요청 (유효하지 않은 키/인증서 형식)")]
|
||||
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||
[SwaggerResponse(403, "권한 없음")]
|
||||
[SwaggerResponse(404, "서비스를 찾을 수 없음")]
|
||||
|
|
|
|||
|
|
@ -7,14 +7,21 @@ public class ApnsCredentialsRequestDto
|
|||
[Required(ErrorMessage = "Bundle ID는 필수입니다.")]
|
||||
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자리여야 합니다.")]
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
public string? KeyId { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Team ID는 필수입니다.")]
|
||||
[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; } = string.Empty;
|
||||
public string? PrivateKey { get; set; }
|
||||
|
||||
// p12 필드 (AuthType=p12일 때 서비스에서 필수 검증)
|
||||
public string? CertificateBase64 { get; set; }
|
||||
|
||||
public string? CertPassword { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,14 @@ public class CredentialsResponseDto
|
|||
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 string? AuthType { get; set; } // "p8" or "p12"
|
||||
// p8 메타
|
||||
public string? KeyId { get; set; }
|
||||
public string? TeamId { get; set; }
|
||||
public bool HasPrivateKey { get; set; }
|
||||
// p12 메타
|
||||
public bool HasCertificate { get; set; }
|
||||
public DateTime? CertExpiresAt { get; set; }
|
||||
}
|
||||
|
||||
public class FcmCredentialsInfoDto
|
||||
|
|
|
|||
|
|
@ -33,14 +33,21 @@ public class ApnsCredentialDto
|
|||
[Required(ErrorMessage = "Bundle ID는 필수입니다.")]
|
||||
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자리여야 합니다.")]
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
public string? KeyId { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Team ID는 필수입니다.")]
|
||||
[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; } = string.Empty;
|
||||
public string? PrivateKey { get; set; }
|
||||
|
||||
// p12 필드
|
||||
public string? CertificateBase64 { get; set; }
|
||||
|
||||
public string? CertPassword { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Linq.Expressions;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
using SPMS.Application.DTOs.Service;
|
||||
using SPMS.Application.Interfaces;
|
||||
|
|
@ -347,13 +348,50 @@ public class ServiceManagementService : IServiceManagementService
|
|||
404);
|
||||
}
|
||||
|
||||
ValidateApnsCredentials(request.PrivateKey);
|
||||
|
||||
// Encrypt and store
|
||||
service.ApnsBundleId = request.BundleId;
|
||||
service.ApnsAuthType = request.AuthType;
|
||||
|
||||
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;
|
||||
|
||||
_serviceRepository.Update(service);
|
||||
|
|
@ -397,8 +435,30 @@ public class ServiceManagementService : IServiceManagementService
|
|||
if (request.Fcm != null)
|
||||
ValidateFcmCredentials(request.Fcm.ServiceAccountJson);
|
||||
|
||||
DateTime? apnsCertExpiresAt = 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. 트랜잭션 시작
|
||||
using var transaction = await _unitOfWork.BeginTransactionAsync();
|
||||
|
|
@ -439,9 +499,20 @@ public class ServiceManagementService : IServiceManagementService
|
|||
if (request.Apns != null)
|
||||
{
|
||||
service.ApnsBundleId = request.Apns.BundleId;
|
||||
service.ApnsAuthType = request.Apns.AuthType;
|
||||
|
||||
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);
|
||||
service.ApnsPrivateKey = _credentialEncryptionService.Encrypt(request.Apns.PrivateKey!);
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
{
|
||||
|
|
@ -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)
|
||||
{
|
||||
var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode);
|
||||
|
|
@ -522,14 +625,17 @@ public class ServiceManagementService : IServiceManagementService
|
|||
var response = new CredentialsResponseDto();
|
||||
|
||||
// APNs info (meta only, no private key)
|
||||
if (!string.IsNullOrEmpty(service.ApnsKeyId))
|
||||
if (!string.IsNullOrEmpty(service.ApnsBundleId))
|
||||
{
|
||||
response.Apns = new ApnsCredentialsInfoDto
|
||||
{
|
||||
BundleId = service.ApnsBundleId ?? string.Empty,
|
||||
KeyId = service.ApnsKeyId ?? string.Empty,
|
||||
TeamId = service.ApnsTeamId ?? string.Empty,
|
||||
HasPrivateKey = !string.IsNullOrEmpty(service.ApnsPrivateKey)
|
||||
BundleId = service.ApnsBundleId,
|
||||
AuthType = service.ApnsAuthType,
|
||||
KeyId = service.ApnsKeyId,
|
||||
TeamId = service.ApnsTeamId,
|
||||
HasPrivateKey = !string.IsNullOrEmpty(service.ApnsPrivateKey),
|
||||
HasCertificate = !string.IsNullOrEmpty(service.ApnsCertificate),
|
||||
CertExpiresAt = service.ApnsCertExpiresAt
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ public class Service : BaseEntity
|
|||
public string? ApnsKeyId { get; set; }
|
||||
public string? ApnsTeamId { 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? WebhookUrl { get; set; }
|
||||
public string? WebhookEvents { get; set; }
|
||||
|
|
|
|||
1130
SPMS.Infrastructure/Migrations/20260225035708_AddApnsP12Support.Designer.cs
generated
Normal file
1130
SPMS.Infrastructure/Migrations/20260225035708_AddApnsP12Support.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -675,10 +675,23 @@ namespace SPMS.Infrastructure.Migrations
|
|||
b.Property<DateTime>("ApiKeyCreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("ApnsAuthType")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("varchar(10)");
|
||||
|
||||
b.Property<string>("ApnsBundleId")
|
||||
.HasMaxLength(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")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("varchar(10)");
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ public class ServiceConfiguration : IEntityTypeConfiguration<Service>
|
|||
builder.Property(e => e.ApnsKeyId).HasMaxLength(10);
|
||||
builder.Property(e => e.ApnsTeamId).HasMaxLength(10);
|
||||
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.WebhookUrl).HasMaxLength(500);
|
||||
builder.Property(e => e.Tags).HasColumnType("json");
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user