SocialDisconnect.cs 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. using Application.Abstractions.Messaging;
  2. using Web.Api.Common;
  3. namespace Web.Api.Endpoints.Auth;
  4. /// <summary>
  5. /// 소셜 "연결 끊기(disconnect/unlink)" webhook — 카카오/네이버 (익명, GET·POST 둘 다 허용).
  6. /// 카카오는 3초 내 2xx 를 요구하므로 검증 통과 시 연동행 삭제 후 항상 200 반환(멱등).
  7. /// 카카오 Admin 키 불일치만 401 로 거부. 수신 파라미터 전체를 정보 로그로 남겨 실제 규격을 확정한다.
  8. /// </summary>
  9. internal sealed class SocialDisconnect : IEndpoint
  10. {
  11. public void MapEndpoint(IEndpointRouteBuilder app)
  12. {
  13. app.MapMethods("api/auth/kakao/disconnect", ["GET", "POST"], (
  14. HttpContext httpContext,
  15. ISender sender,
  16. ILogger<SocialDisconnect> logger,
  17. CancellationToken ct
  18. ) => HandleAsync("Kakao", "user_id", httpContext, sender, logger, ct))
  19. .WithTags("Auth")
  20. .AllowAnonymous();
  21. app.MapMethods("api/auth/naver/disconnect", ["GET", "POST"], (
  22. HttpContext httpContext,
  23. ISender sender,
  24. ILogger<SocialDisconnect> logger,
  25. CancellationToken ct
  26. ) => HandleAsync("Naver", "id", httpContext, sender, logger, ct))
  27. .WithTags("Auth")
  28. .AllowAnonymous();
  29. }
  30. private static async Task<IResult> HandleAsync(
  31. string provider,
  32. string userKeyParam,
  33. HttpContext httpContext,
  34. ISender sender,
  35. ILogger<SocialDisconnect> logger,
  36. CancellationToken ct
  37. ) {
  38. var parameters = await CollectParametersAsync(httpContext, ct);
  39. // 규격 확정용: 수신 파라미터 전체 + Authorization 존재 여부를 정보 로그로 남긴다 (헤더 원문/토큰은 남기지 않음).
  40. logger.LogInformation(
  41. "{Provider} disconnect 수신 — method={Method}, hasAuthorization={HasAuth}, params={Params}",
  42. provider,
  43. httpContext.Request.Method,
  44. httpContext.Request.Headers.ContainsKey("Authorization"),
  45. FormatParameters(parameters)
  46. );
  47. var providerUserKey = parameters.TryGetValue(userKeyParam, out var key) ? key : null;
  48. var adminKeyHeader = httpContext.Request.Headers.Authorization.ToString();
  49. var command = new Application.Features.Api.Auth.SocialDisconnect.Command(
  50. provider,
  51. providerUserKey,
  52. string.IsNullOrEmpty(adminKeyHeader) ? null : adminKeyHeader
  53. );
  54. var result = await sender.Send(command, ct);
  55. // 성공/멱등은 항상 200. Admin 키 불일치 등 검증 실패만 해당 상태코드로 거부.
  56. if (result.IsSuccess)
  57. {
  58. return Results.Ok(new { provider = result.Value.Provider, removed = result.Value.Removed });
  59. }
  60. return CustomResults.Problem(result);
  61. }
  62. /// <summary>쿼리스트링(GET)·form-urlencoded(POST) 양쪽에서 파라미터를 수집. form 값이 우선.</summary>
  63. private static async Task<Dictionary<string, string>> CollectParametersAsync(HttpContext httpContext, CancellationToken ct)
  64. {
  65. var parameters = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
  66. foreach (var kv in httpContext.Request.Query)
  67. {
  68. parameters[kv.Key] = kv.Value.ToString();
  69. }
  70. if (httpContext.Request.HasFormContentType)
  71. {
  72. var form = await httpContext.Request.ReadFormAsync(ct);
  73. foreach (var kv in form)
  74. {
  75. parameters[kv.Key] = kv.Value.ToString();
  76. }
  77. }
  78. return parameters;
  79. }
  80. private static string FormatParameters(Dictionary<string, string> parameters)
  81. {
  82. return parameters.Count == 0 ? "(none)" : string.Join(", ", parameters.Select(c => $"{c.Key}={c.Value}"));
  83. }
  84. }