Handler.cs 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. using Application.Abstractions.Crypto;
  2. using Application.Abstractions.Data;
  3. using Application.Abstractions.Messaging;
  4. using SharedKernel;
  5. using SharedKernel.Results;
  6. using Microsoft.EntityFrameworkCore;
  7. using Microsoft.Extensions.Logging;
  8. using Microsoft.Extensions.Options;
  9. namespace Application.Features.Api.Auth.SocialDisconnect;
  10. /// <summary>
  11. /// 소셜 연결 끊기(disconnect) 처리.
  12. /// 카카오: Authorization 헤더의 Admin 키가 우리 설정(OAuth:Kakao:AdminKey)과 일치해야 함 → 불일치 시 Unauthorized 로 거부.
  13. /// 네이버: 공식 규격 미확정 → 우선 관대하게 수신(검증 없이). ProviderUserKey 매칭되면 삭제, 아니면 멱등 성공.
  14. /// 매칭 실패해도 성공(멱등) — 이미 해제됐을 수 있음. 카카오 3초 내 2xx 요구 대응.
  15. /// </summary>
  16. internal sealed class Handler(
  17. IAppDbContext db,
  18. IFieldEncryptor fieldEncryptor,
  19. IOptions<AppSettings> options,
  20. ILogger<Handler> logger
  21. ) : ICommandHandler<Command, Result<Response>> {
  22. private const string NaverProvider = "Naver";
  23. private const string KakaoProvider = "Kakao";
  24. private readonly AppSettings.OAuthSection _oauth = options.Value.OAuth;
  25. public async Task<Result<Response>> Handle(Command request, CancellationToken ct)
  26. {
  27. var providerName = NormalizeProvider(request.Provider);
  28. if (providerName is null)
  29. {
  30. return Result.Failure<Response>(Error.Problem("Auth.UnsupportedProvider", "지원하지 않는 소셜 연결 끊기입니다."));
  31. }
  32. // 카카오는 Admin 키 검증 (Authorization 헤더). 설정이 비어 있으면 검증 자체를 통과시킬 수 없어 거부.
  33. if (providerName == KakaoProvider)
  34. {
  35. // Admin 키는 DB Config(External, Admin 관리) 우선 — 없으면 appsettings 폴백
  36. var ext = (await db.Config.AsNoTracking().OrderByDescending(c => c.ID).FirstOrDefaultAsync(ct))?.External;
  37. var dbAdminKey = string.IsNullOrWhiteSpace(ext?.KakaoAdminKeyEnc) ? null : EncryptedConfigValue.Unprotect(fieldEncryptor, ext!.KakaoAdminKeyEnc);
  38. var adminKey = !string.IsNullOrWhiteSpace(dbAdminKey) ? dbAdminKey : _oauth.Kakao.AdminKey;
  39. if (string.IsNullOrWhiteSpace(adminKey) || !MatchesKakaoAdminKey(request.AdminKeyHeader, adminKey))
  40. {
  41. logger.LogWarning("Kakao disconnect 거부 — Admin 키 불일치 (설정 여부={Configured})", !string.IsNullOrWhiteSpace(adminKey));
  42. return Result.Failure<Response>(Error.Unauthorized("Auth.DisconnectUnauthorized", "유효하지 않은 요청입니다."));
  43. }
  44. }
  45. if (string.IsNullOrWhiteSpace(request.ProviderUserKey))
  46. {
  47. // 매칭 키가 없으면 삭제할 수 없으나 멱등 성공으로 처리 (규격 확정 전 로그만 남김)
  48. logger.LogInformation("{Provider} disconnect: ProviderUserKey 없음 — 멱등 성공 처리", providerName);
  49. return Result.Success(new Response(providerName, 0));
  50. }
  51. var providerUserKey = request.ProviderUserKey.Trim();
  52. var links = await db.MemberOAuthToken.Where(c => c.Provider == providerName && c.ProviderUserKey == providerUserKey).ToListAsync(ct);
  53. if (links.Count == 0)
  54. {
  55. logger.LogInformation("{Provider} disconnect: 매칭 연동행 없음 (멱등) — key={Key}", providerName, providerUserKey);
  56. return Result.Success(new Response(providerName, 0));
  57. }
  58. db.MemberOAuthToken.RemoveRange(links);
  59. await db.SaveChangesAsync(ct);
  60. logger.LogInformation("{Provider} disconnect: 연동행 {Count}건 삭제 — key={Key}", providerName, links.Count, providerUserKey);
  61. return Result.Success(new Response(providerName, links.Count));
  62. }
  63. private static string? NormalizeProvider(string? provider)
  64. {
  65. return provider?.Trim().ToLowerInvariant() switch
  66. {
  67. "naver" => NaverProvider,
  68. "kakao" => KakaoProvider,
  69. _ => null
  70. };
  71. }
  72. /// <summary>
  73. /// 카카오 Authorization 헤더 대조. 헤더는 "KakaoAK {ADMIN_KEY}" 형태로 오는 것이 표준이나,
  74. /// Admin 키 원문만 오는 경우도 관대하게 허용한다.
  75. /// </summary>
  76. private static bool MatchesKakaoAdminKey(string? headerValue, string adminKey)
  77. {
  78. if (string.IsNullOrWhiteSpace(headerValue))
  79. {
  80. return false;
  81. }
  82. var value = headerValue.Trim();
  83. if (value.StartsWith("KakaoAK ", StringComparison.OrdinalIgnoreCase))
  84. {
  85. value = value["KakaoAK ".Length..].Trim();
  86. }
  87. return string.Equals(value, adminKey.Trim(), StringComparison.Ordinal);
  88. }
  89. }