using Application.Abstractions.Data; using Application.Abstractions.Messaging; using SharedKernel; using SharedKernel.Results; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Application.Features.Api.Auth.SocialDisconnect; /// /// 소셜 연결 끊기(disconnect) 처리. /// 카카오: Authorization 헤더의 Admin 키가 우리 설정(OAuth:Kakao:AdminKey)과 일치해야 함 → 불일치 시 Unauthorized 로 거부. /// 네이버: 공식 규격 미확정 → 우선 관대하게 수신(검증 없이). ProviderUserKey 매칭되면 삭제, 아니면 멱등 성공. /// 매칭 실패해도 성공(멱등) — 이미 해제됐을 수 있음. 카카오 3초 내 2xx 요구 대응. /// internal sealed class Handler( IAppDbContext db, IOptions options, ILogger logger ) : ICommandHandler> { private const string NaverProvider = "Naver"; private const string KakaoProvider = "Kakao"; private readonly AppSettings.OAuthSection _oauth = options.Value.OAuth; public async Task> Handle(Command request, CancellationToken ct) { var providerName = NormalizeProvider(request.Provider); if (providerName is null) { return Result.Failure(Error.Problem("Auth.UnsupportedProvider", "지원하지 않는 소셜 연결 끊기입니다.")); } // 카카오는 Admin 키 검증 (Authorization 헤더). 설정이 비어 있으면 검증 자체를 통과시킬 수 없어 거부. if (providerName == KakaoProvider) { var adminKey = _oauth.Kakao.AdminKey; if (string.IsNullOrWhiteSpace(adminKey) || !MatchesKakaoAdminKey(request.AdminKeyHeader, adminKey)) { logger.LogWarning("Kakao disconnect 거부 — Admin 키 불일치 (설정 여부={Configured})", !string.IsNullOrWhiteSpace(adminKey)); return Result.Failure(Error.Unauthorized("Auth.DisconnectUnauthorized", "유효하지 않은 요청입니다.")); } } if (string.IsNullOrWhiteSpace(request.ProviderUserKey)) { // 매칭 키가 없으면 삭제할 수 없으나 멱등 성공으로 처리 (규격 확정 전 로그만 남김) logger.LogInformation("{Provider} disconnect: ProviderUserKey 없음 — 멱등 성공 처리", providerName); return Result.Success(new Response(providerName, 0)); } var providerUserKey = request.ProviderUserKey.Trim(); var links = await db.MemberOAuthToken.Where(c => c.Provider == providerName && c.ProviderUserKey == providerUserKey).ToListAsync(ct); if (links.Count == 0) { logger.LogInformation("{Provider} disconnect: 매칭 연동행 없음 (멱등) — key={Key}", providerName, providerUserKey); return Result.Success(new Response(providerName, 0)); } db.MemberOAuthToken.RemoveRange(links); await db.SaveChangesAsync(ct); logger.LogInformation("{Provider} disconnect: 연동행 {Count}건 삭제 — key={Key}", providerName, links.Count, providerUserKey); return Result.Success(new Response(providerName, links.Count)); } private static string? NormalizeProvider(string? provider) { return provider?.Trim().ToLowerInvariant() switch { "naver" => NaverProvider, "kakao" => KakaoProvider, _ => null }; } /// /// 카카오 Authorization 헤더 대조. 헤더는 "KakaoAK {ADMIN_KEY}" 형태로 오는 것이 표준이나, /// Admin 키 원문만 오는 경우도 관대하게 허용한다. /// private static bool MatchesKakaoAdminKey(string? headerValue, string adminKey) { if (string.IsNullOrWhiteSpace(headerValue)) { return false; } var value = headerValue.Trim(); if (value.StartsWith("KakaoAK ", StringComparison.OrdinalIgnoreCase)) { value = value["KakaoAK ".Length..].Trim(); } return string.Equals(value, adminKey.Trim(), StringComparison.Ordinal); } }