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);
}
}