Эх сурвалжийг харах

M1-D5: Naver/Kakao OAuth providers, social login endpoints (+list Volume)

- ISocialAuthProvider + NaverAuthProvider/KakaoAuthProvider (state required, kakao optional email)
- POST /api/auth/social-login, GET /api/auth/{provider}/authorize-url
- No auto account-link (Auth.AccountExists), kakao email-missing -> Auth.EmailRequired
- OAuth:Naver/Kakao settings keys (empty values), no schema change
- GetList Row.Volume via latest StockDailyPrice subquery
- Tests 41/41

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 3 долоо хоног өмнө
parent
commit
3d8e49ca42

+ 41 - 0
Application/Abstractions/Authentication/ISocialAuthProvider.cs

@@ -0,0 +1,41 @@
+namespace Application.Abstractions.Authentication;
+
+/// <summary>소셜 provider 토큰 교환 결과 (authorization code → access token)</summary>
+public sealed record SocialTokens(
+    string AccessToken,
+    string? RefreshToken,
+    DateTime ExpiresAt,
+    string[] Scopes
+);
+
+/// <summary>
+/// 소셜 provider 프로필 조회 결과.
+/// Email 은 카카오 선택동의 미동의 시 null. EmailVerified 는 provider 가 검증을 보장하는 경우만 true
+/// (네이버는 연락처 이메일이라 항상 false — d5 §③ 계정 매칭 정책).
+/// </summary>
+public sealed record SocialUserInfo(
+    string ProviderUserKey,
+    string? Email,
+    bool EmailVerified,
+    string? Nickname,
+    string? ProfileImageUrl
+);
+
+/// <summary>
+/// Naver/Kakao authorization code 로그인 provider (d5 §③).
+/// Google 로그인은 GIS id_token 방식(IGoogleTokenValidator) 현행 유지 — 비목표.
+/// </summary>
+public interface ISocialAuthProvider
+{
+    /// <summary>"Naver" | "Kakao"</summary>
+    string Provider { get; }
+
+    /// <summary>동의 화면 authorize URL 생성 (state 는 호출자가 생성/보관 후 콜백에서 대조)</summary>
+    string GetAuthorizationUrl(string clientId, string redirectUri, string state);
+
+    /// <summary>승인 코드 → 토큰 교환. 실패 시 null (사유는 provider 가 로깅)</summary>
+    Task<SocialTokens?> ExchangeCodeAsync(string code, string redirectUri, string? state, string clientId, string? clientSecret, CancellationToken ct);
+
+    /// <summary>액세스 토큰으로 프로필 조회. 실패 시 null</summary>
+    Task<SocialUserInfo?> GetUserInfoAsync(string accessToken, CancellationToken ct);
+}

+ 61 - 0
Application/Features/Api/Auth/SocialAuthorizeUrl/Handler.cs

@@ -0,0 +1,61 @@
+using Application.Abstractions.Authentication;
+using Application.Abstractions.Messaging;
+using SharedKernel;
+using SharedKernel.Results;
+using Microsoft.Extensions.Options;
+
+namespace Application.Features.Api.Auth.SocialAuthorizeUrl;
+
+internal sealed class Handler(
+    IEnumerable<ISocialAuthProvider> socialProviders,
+    IOptions<AppSettings> options
+) : IQueryHandler<Query, Result<Response>> {
+
+    private readonly AppSettings.OAuthSection _oauth = options.Value.OAuth;
+
+    public Task<Result<Response>> Handle(Query request, CancellationToken ct)
+    {
+        var providerName = request.Provider?.Trim().ToLowerInvariant() switch
+        {
+            "naver" => "Naver",
+            "kakao" => "Kakao",
+            _ => null
+        };
+
+        if (providerName is null)
+        {
+            return Task.FromResult(Result.Failure<Response>(Error.Problem("Auth.UnsupportedProvider", "지원하지 않는 소셜 로그인입니다.")));
+        }
+
+        // 네이버는 스펙상 필수, 카카오도 CSRF 방어 위해 항상 요구 (d5 §③)
+        if (string.IsNullOrWhiteSpace(request.State))
+        {
+            return Task.FromResult(Result.Failure<Response>(Error.Problem("Auth.StateRequired", "state 값이 필요합니다.")));
+        }
+
+        var oauthConfig = providerName == "Naver" ? _oauth.Naver : _oauth.Kakao;
+
+        if (string.IsNullOrWhiteSpace(oauthConfig.ClientId))
+        {
+            return Task.FromResult(Result.Failure<Response>(Error.Problem("Auth.SocialNotConfigured", $"{providerName} 로그인이 설정되지 않았습니다.")));
+        }
+
+        var redirectUri = string.IsNullOrWhiteSpace(request.RedirectUri) ? oauthConfig.RedirectUri : request.RedirectUri;
+
+        if (string.IsNullOrWhiteSpace(redirectUri))
+        {
+            return Task.FromResult(Result.Failure<Response>(Error.Problem("Auth.RedirectUriRequired", "Redirect URI 가 필요합니다.")));
+        }
+
+        var provider = socialProviders.FirstOrDefault(c => c.Provider == providerName);
+
+        if (provider is null)
+        {
+            return Task.FromResult(Result.Failure<Response>(Error.Problem("Auth.UnsupportedProvider", "지원하지 않는 소셜 로그인입니다.")));
+        }
+
+        var url = provider.GetAuthorizationUrl(oauthConfig.ClientId, redirectUri, request.State);
+
+        return Task.FromResult(Result.Success(new Response(providerName, url)));
+    }
+}

+ 14 - 0
Application/Features/Api/Auth/SocialAuthorizeUrl/Query.cs

@@ -0,0 +1,14 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Auth.SocialAuthorizeUrl;
+
+/// <summary>
+/// 소셜 로그인 시작 URL 생성 (Naver/Kakao). state 는 호출자(프론트 start 라우트)가 생성해
+/// httpOnly 쿠키로 보관 후 콜백에서 대조한다 — 백엔드는 URL 조립만 담당.
+/// </summary>
+public sealed record Query(
+    string Provider,
+    string State,
+    string? RedirectUri = null
+) : IQuery<Result<Response>>;

+ 6 - 0
Application/Features/Api/Auth/SocialAuthorizeUrl/Response.cs

@@ -0,0 +1,6 @@
+namespace Application.Features.Api.Auth.SocialAuthorizeUrl;
+
+public sealed record Response(
+    string Provider,
+    string AuthorizeUrl
+);

+ 18 - 0
Application/Features/Api/Auth/SocialLogin/Command.cs

@@ -0,0 +1,18 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Auth.SocialLogin;
+
+/// <summary>
+/// 소셜 로그인 (Naver/Kakao) — authorization code 를 토큰으로 교환하고 프로필 매칭 후 JWT 발급.
+/// RedirectUri 미전달 시 AppSettings OAuth:{Provider}:RedirectUri 사용.
+/// State 는 네이버 필수 (프론트가 쿠키 대조를 마친 값을 그대로 전달 — 토큰 교환에도 쓰임).
+/// </summary>
+public sealed record Command(
+    string Provider,
+    string Code,
+    string? State = null,
+    string? RedirectUri = null,
+    string? IpAddress = null,
+    string? UserAgent = null
+) : ICommand<Result<Response>>;

+ 259 - 0
Application/Features/Api/Auth/SocialLogin/Handler.cs

@@ -0,0 +1,259 @@
+using Application.Abstractions.Authentication;
+using Application.Abstractions.Cache;
+using Application.Abstractions.Crypto;
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Application.Helpers;
+using Domain.Entities.Members;
+using Domain.Entities.Members.Logs;
+using SharedKernel;
+using SharedKernel.Results;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Application.Features.Api.Auth.SocialLogin;
+
+/// <summary>
+/// Naver/Kakao 소셜 로그인 (d5 §③④ — d0 확정 반영).
+/// 매칭 정책: 이메일로 기존 회원 조회 → 해당 provider 연동(MemberOAuthToken) 있으면 로그인,
+/// 없으면 Auth.AccountExists 실패 (자동 계정연결 금지 — 연결은 로그인 후 sns-link 흐름).
+/// 기존 회원이 없으면 자동가입 + 연동 행 생성. 카카오 이메일 미동의는 Auth.EmailRequired 실패.
+/// </summary>
+internal sealed class Handler(
+    IAppDbContext db,
+    IJwtTokenProvider jwtTokenProvider,
+    IEnumerable<ISocialAuthProvider> socialProviders,
+    IFieldEncryptor fieldEncryptor,
+    IOptions<AppSettings> options,
+    ILogger<Handler> logger,
+    ICacheService cache
+) : ICommandHandler<Command, Result<Response>> {
+
+    private const string NaverProvider = "Naver";
+    private const string KakaoProvider = "Kakao";
+
+    private readonly AppSettings.JwtSection _jwt = options.Value.JWT;
+    private readonly AppSettings.OAuthSection _oauth = options.Value.OAuth;
+
+    public async Task<Result<Response>> Handle(Command request, CancellationToken ct)
+    {
+        // provider 화이트리스트 (Google 은 기존 google-login 경로 유지 — 여기 미포함)
+        var providerName = NormalizeProvider(request.Provider);
+
+        if (providerName is null)
+        {
+            return Result.Failure<Response>(Error.Problem("Auth.UnsupportedProvider", "지원하지 않는 소셜 로그인입니다."));
+        }
+
+        if (string.IsNullOrWhiteSpace(request.Code))
+        {
+            return Result.Failure<Response>(Error.Problem("Auth.CodeRequired", "Authorization code 가 필요합니다."));
+        }
+
+        // 네이버는 state 가 스펙상 필수 (CSRF 방어)
+        if (providerName == NaverProvider && string.IsNullOrWhiteSpace(request.State))
+        {
+            return Result.Failure<Response>(Error.Problem("Auth.StateRequired", "state 값이 필요합니다."));
+        }
+
+        var oauthConfig = providerName == NaverProvider ? _oauth.Naver : _oauth.Kakao;
+
+        if (string.IsNullOrWhiteSpace(oauthConfig.ClientId))
+        {
+            return Result.Failure<Response>(Error.Problem("Auth.SocialNotConfigured", $"{providerName} 로그인이 설정되지 않았습니다."));
+        }
+
+        // 네이버는 client_secret 필수, 카카오는 콘솔 활성화 시에만 전송
+        if (providerName == NaverProvider && string.IsNullOrWhiteSpace(oauthConfig.ClientSecret))
+        {
+            return Result.Failure<Response>(Error.Problem("Auth.SocialNotConfigured", $"{providerName} 로그인이 설정되지 않았습니다."));
+        }
+
+        var redirectUri = string.IsNullOrWhiteSpace(request.RedirectUri) ? oauthConfig.RedirectUri : request.RedirectUri;
+
+        if (string.IsNullOrWhiteSpace(redirectUri))
+        {
+            return Result.Failure<Response>(Error.Problem("Auth.RedirectUriRequired", "Redirect URI 가 필요합니다."));
+        }
+
+        var provider = socialProviders.FirstOrDefault(c => c.Provider == providerName);
+
+        if (provider is null)
+        {
+            return Result.Failure<Response>(Error.Problem("Auth.UnsupportedProvider", "지원하지 않는 소셜 로그인입니다."));
+        }
+
+        // 승인 코드 → 토큰 교환
+        var clientSecret = string.IsNullOrWhiteSpace(oauthConfig.ClientSecret) ? null : oauthConfig.ClientSecret;
+        var tokens = await provider.ExchangeCodeAsync(request.Code, redirectUri, request.State, oauthConfig.ClientId, clientSecret, ct);
+
+        if (tokens is null)
+        {
+            return Result.Failure<Response>(Error.Unauthorized("Auth.SocialTokenExchangeFailed", $"{providerName} 인증 코드 교환에 실패했습니다."));
+        }
+
+        // 프로필 조회
+        var userInfo = await provider.GetUserInfoAsync(tokens.AccessToken, ct);
+
+        if (userInfo is null)
+        {
+            return Result.Failure<Response>(Error.Unauthorized("Auth.SocialUserInfoFailed", $"{providerName} 프로필 조회에 실패했습니다."));
+        }
+
+        // 카카오 이메일 선택동의 미동의 → 프론트가 이메일 보완 화면으로 유도할 수 있게 에러 코드 분리
+        if (string.IsNullOrWhiteSpace(userInfo.Email))
+        {
+            return Result.Failure<Response>(Error.Problem("Auth.EmailRequired", "이메일 제공에 동의해야 가입할 수 있습니다."));
+        }
+
+        var email = userInfo.Email.Trim().ToLower();
+
+        var member = await db.Member.FirstOrDefaultAsync(c => c.Email == email, ct);
+
+        if (member is null)
+        {
+            // 자동 회원가입 (GoogleLogin 시퀀스 재사용)
+            var accountConfig = await AccountConfigLoader.GetAccountConfigAsync(cache, db, ct);
+
+            if (accountConfig.IsRegisterBlock)
+            {
+                return Result.Failure<Response>(Error.Problem("Auth.RegisterBlocked", "현재 회원가입이 차단되어 있습니다."));
+            }
+
+            member = Domain.Entities.Members.Member.Create(email);
+
+            await db.Member.AddAsync(member, ct);
+            await db.SaveChangesAsync(ct);
+
+            await db.MemberApprove.AddAsync(MemberApprove.Create(member.ID), ct);
+
+            await db.MemberStats.AddAsync(MemberStats.Create(member.ID), ct);
+
+            await db.Wallet.AddAsync(Domain.Entities.Wallets.Wallet.Create(member.ID), ct);
+
+            // provider 가 검증을 보장하는 이메일만 인증 완료 처리 (카카오 is_email_verified)
+            // 네이버는 연락처 이메일이라 미검증 유지 → 기존 이메일 인증(resend-email) 흐름으로 보완
+            if (userInfo.EmailVerified)
+            {
+                member.MarkEmailVerified();
+            }
+
+            await db.SaveChangesAsync(ct);
+
+            logger.LogInformation("{Provider} 자동 회원가입: {Email}", providerName, email);
+        }
+        else
+        {
+            // 탈퇴 회원 거부
+            if (member.IsWithdraw)
+            {
+                await LogLoginAttempt(member.ID, false, email, $"탈퇴 회원 ({providerName})", request, ct);
+                return Result.Failure<Response>(Error.Problem("Auth.MemberWithdrawn", "탈퇴한 회원은 이용할 수 없습니다."));
+            }
+
+            // 차단 회원 거부
+            if (member.IsDenied)
+            {
+                await LogLoginAttempt(member.ID, false, email, $"차단 회원 ({providerName})", request, ct);
+                return Result.Failure<Response>(Error.Problem("Auth.MemberDenied", "차단된 회원이므로 이용할 수 없습니다."));
+            }
+
+            // 자동 계정연결 금지 (d0 §5-⑦): 같은 이메일의 기존 계정이 있어도 해당 provider 연동이 없으면 로그인 불가
+            var hasLink = await db.MemberOAuthToken.AnyAsync(c => c.MemberID == member.ID && c.Provider == providerName, ct);
+
+            if (!hasLink)
+            {
+                await LogLoginAttempt(member.ID, false, email, $"미연동 소셜 로그인 시도 ({providerName})", request, ct);
+                return Result.Failure<Response>(Error.Conflict("Auth.AccountExists", "이미 가입된 이메일입니다. 기존 계정으로 로그인 후 SNS 연동을 이용해주세요."));
+            }
+        }
+
+        // 연동 토큰 upsert (신규 저장분은 IFieldEncryptor 암호화 — d5 R2)
+        await UpsertOAuthToken(member.ID, providerName, tokens, ct);
+
+        // JWT 토큰 생성
+        var accessToken = jwtTokenProvider.CreateAccessToken(member.ID, member.Email, member.Name);
+        var refreshToken = jwtTokenProvider.CreateRefreshToken();
+        var expiresAt = DateTime.UtcNow.AddMinutes(_jwt.AccessTokenExpiration);
+
+        // RefreshToken 저장
+        var refreshTokenEntity = Domain.Entities.Members.RefreshToken.Create(
+            member.ID,
+            refreshToken,
+            DateTime.UtcNow.AddDays(_jwt.RefreshTokenExpiration)
+        );
+
+        await db.RefreshToken.AddAsync(refreshTokenEntity, ct);
+
+        // 로그인 횟수 증가
+        var memberStats = await db.MemberStats.FirstOrDefaultAsync(c => c.MemberID == member.ID, ct);
+        if (memberStats is not null)
+        {
+            memberStats.LoginCount++;
+        }
+
+        // 최종 접속 정보 갱신
+        member.RecordLogin(request.IpAddress, request.UserAgent);
+
+        // 성공 로그 기록
+        await LogLoginAttempt(member.ID, true, email, providerName, request, ct);
+
+        await db.SaveChangesAsync(ct);
+
+        logger.LogInformation("{Email} {Provider} 로그인", member.Email, providerName);
+
+        return Result.Success(new Response(accessToken, refreshToken, expiresAt));
+    }
+
+    private static string? NormalizeProvider(string? provider)
+    {
+        return provider?.Trim().ToLowerInvariant() switch
+        {
+            "naver" => NaverProvider,
+            "kakao" => KakaoProvider,
+            _ => null
+        };
+    }
+
+    private async Task UpsertOAuthToken(int memberID, string providerName, SocialTokens tokens, CancellationToken ct)
+    {
+        var accessTokenEnc = fieldEncryptor.Encrypt(tokens.AccessToken);
+        var refreshTokenEnc = tokens.RefreshToken is null ? null : fieldEncryptor.Encrypt(tokens.RefreshToken);
+        var scopes = string.Join(" ", tokens.Scopes);
+
+        var existing = await db.MemberOAuthToken.FirstOrDefaultAsync(c => c.MemberID == memberID && c.Provider == providerName, ct);
+
+        if (existing is null)
+        {
+            var token = MemberOAuthToken.Create(
+                memberID,
+                providerName,
+                accessTokenEnc,
+                refreshTokenEnc,
+                tokens.ExpiresAt,
+                scopes
+            );
+            db.MemberOAuthToken.Add(token);
+        }
+        else
+        {
+            existing.UpdateTokens(accessTokenEnc, refreshTokenEnc, tokens.ExpiresAt, scopes);
+        }
+    }
+
+    private async Task LogLoginAttempt(int? memberID, bool success, string account, string? reason, Command request, CancellationToken ct)
+    {
+        var log = MemberLoginLog.Create(
+            memberID,
+            success,
+            account,
+            reason,
+            ipAddress: request.IpAddress,
+            userAgent: request.UserAgent
+        );
+
+        await db.MemberLoginLog.AddAsync(log, ct);
+        await db.SaveChangesAsync(ct);
+    }
+}

+ 7 - 0
Application/Features/Api/Auth/SocialLogin/Response.cs

@@ -0,0 +1,7 @@
+namespace Application.Features.Api.Auth.SocialLogin;
+
+public sealed record Response(
+    string AccessToken,
+    string RefreshToken,
+    DateTime ExpiresAt
+);

+ 5 - 2
Application/Features/Api/Stocks/GetList/Handler.cs

@@ -41,7 +41,9 @@ internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
                 c.LastTradingDate,
                 c.LastClosePrice,
                 c.LastChangeRate,
-                c.MarketCap
+                c.MarketCap,
+                // 전일 거래량 — Stock denorm 에 없어 최근 거래일(LastTradingDate)의 StockDailyPrice 를 상관 서브쿼리로 조회
+                Volume = db.StockDailyPrice.Where(p => p.StockID == c.ID && p.TradingDate == c.LastTradingDate).Select(p => (long?)p.Volume).FirstOrDefault()
             })
             .ToListAsync(ct);
 
@@ -57,7 +59,8 @@ internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
                 LastTradingDate = c.LastTradingDate,
                 ClosePrice = c.LastClosePrice,
                 ChangeRate = c.LastChangeRate,
-                MarketCap = c.MarketCap
+                MarketCap = c.MarketCap,
+                Volume = c.Volume
             })]
         };
     }

+ 3 - 0
Application/Features/Api/Stocks/GetList/Response.cs

@@ -15,5 +15,8 @@ public sealed class Response
         public int? ClosePrice { get; init; }
         public decimal? ChangeRate { get; init; }
         public long? MarketCap { get; init; }
+
+        /// <summary>전일(최근 거래일) 거래량 — 최근 StockDailyPrice. 시세 미수집 시 null</summary>
+        public long? Volume { get; init; }
     }
 }

+ 184 - 0
Infrastructure/Authentication/KakaoAuthProvider.cs

@@ -0,0 +1,184 @@
+using System.Text.Json;
+using Application.Abstractions.Authentication;
+using Microsoft.Extensions.Logging;
+
+namespace Infrastructure.Authentication;
+
+/// <summary>
+/// 카카오 로그인 (OAuth 2.0 authorization code). client_id 는 REST API 키.
+/// account_email 은 기본 선택동의 (필수동의 전환은 비즈앱 필요 — d0 §5-⑦ 추후) → email null 가능.
+/// client_secret 은 콘솔에서 활성화한 경우에만 필수 — 미설정(빈 값)이면 전송하지 않는다.
+/// </summary>
+public sealed class KakaoAuthProvider(
+    HttpClient httpClient,
+    ILogger<KakaoAuthProvider> logger
+) : ISocialAuthProvider
+{
+    public const string ProviderName = "Kakao";
+
+    private const string AuthEndpoint = "https://kauth.kakao.com/oauth/authorize";
+    private const string TokenEndpoint = "https://kauth.kakao.com/oauth/token";
+    private const string UserInfoEndpoint = "https://kapi.kakao.com/v2/user/me";
+    private const string Scope = "account_email profile_nickname profile_image";
+
+    public string Provider => ProviderName;
+
+    public string GetAuthorizationUrl(string clientId, string redirectUri, string state)
+    {
+        var parameters = new Dictionary<string, string>
+        {
+            ["response_type"] = "code",
+            ["client_id"] = clientId,
+            ["redirect_uri"] = redirectUri,
+            ["state"] = state, // 스펙상 선택이나 CSRF 방어 위해 항상 전송 (d5 §③)
+            ["scope"] = Scope
+        };
+
+        var query = string.Join("&", parameters.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
+
+        return $"{AuthEndpoint}?{query}";
+    }
+
+    public async Task<SocialTokens?> ExchangeCodeAsync(string code, string redirectUri, string? state, string clientId, string? clientSecret, CancellationToken ct)
+    {
+        try
+        {
+            var form = new Dictionary<string, string>
+            {
+                ["grant_type"] = "authorization_code",
+                ["client_id"] = clientId,
+                ["redirect_uri"] = redirectUri,
+                ["code"] = code
+            };
+
+            if (!string.IsNullOrWhiteSpace(clientSecret))
+            {
+                form["client_secret"] = clientSecret;
+            }
+
+            var response = await httpClient.PostAsync(TokenEndpoint, new FormUrlEncodedContent(form), ct);
+            var json = await response.Content.ReadAsStringAsync(ct);
+
+            if (!response.IsSuccessStatusCode)
+            {
+                logger.LogWarning("Kakao OAuth code exchange failed: {StatusCode} {Body}", response.StatusCode, json);
+                return null;
+            }
+
+            var tokens = ParseTokens(json);
+
+            if (tokens is null)
+            {
+                logger.LogWarning("Kakao OAuth token response invalid: {Body}", json);
+            }
+
+            return tokens;
+        }
+        catch (Exception ex)
+        {
+            logger.LogError(ex, "Kakao OAuth code exchange error");
+            return null;
+        }
+    }
+
+    public async Task<SocialUserInfo?> GetUserInfoAsync(string accessToken, CancellationToken ct)
+    {
+        try
+        {
+            using var request = new HttpRequestMessage(HttpMethod.Get, UserInfoEndpoint);
+            request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
+
+            var response = await httpClient.SendAsync(request, ct);
+            var json = await response.Content.ReadAsStringAsync(ct);
+
+            if (!response.IsSuccessStatusCode)
+            {
+                logger.LogWarning("Kakao user info failed: {StatusCode} {Body}", response.StatusCode, json);
+                return null;
+            }
+
+            var userInfo = ParseUserInfo(json);
+
+            if (userInfo is null)
+            {
+                logger.LogWarning("Kakao user info response invalid: {Body}", json);
+            }
+
+            return userInfo;
+        }
+        catch (Exception ex)
+        {
+            logger.LogError(ex, "Kakao user info error");
+            return null;
+        }
+    }
+
+    /// <summary>토큰 응답 파싱 — scope 는 공백 구분 문자열</summary>
+    public static SocialTokens? ParseTokens(string json)
+    {
+        var doc = JsonSerializer.Deserialize<JsonElement>(json);
+
+        if (doc.ValueKind != JsonValueKind.Object || doc.TryGetProperty("error", out _))
+        {
+            return null;
+        }
+
+        if (!doc.TryGetProperty("access_token", out var accessTokenProp) || accessTokenProp.GetString() is not { Length: > 0 } accessToken)
+        {
+            return null;
+        }
+
+        var refreshToken = doc.TryGetProperty("refresh_token", out var rtProp) ? rtProp.GetString() : null;
+        var expiresIn = NaverAuthProvider.ReadExpiresIn(doc);
+        var scope = doc.TryGetProperty("scope", out var scopeProp) ? scopeProp.GetString() ?? string.Empty : string.Empty;
+
+        return new SocialTokens(
+            accessToken,
+            refreshToken,
+            DateTime.UtcNow.AddSeconds(expiresIn - 60), // 60초 여유
+            scope.Split(' ', StringSplitOptions.RemoveEmptyEntries)
+        );
+    }
+
+    /// <summary>
+    /// 프로필 응답 파싱 — id 는 int64 (문자열화하여 ProviderUserKey 로 사용).
+    /// email 은 선택동의 미동의 시 부재 → null. EmailVerified 는 is_email_verified 그대로.
+    /// </summary>
+    public static SocialUserInfo? ParseUserInfo(string json)
+    {
+        var doc = JsonSerializer.Deserialize<JsonElement>(json);
+
+        if (doc.ValueKind != JsonValueKind.Object || !doc.TryGetProperty("id", out var idProp) || idProp.ValueKind != JsonValueKind.Number)
+        {
+            return null;
+        }
+
+        var id = idProp.GetInt64().ToString();
+
+        string? email = null;
+        var emailVerified = false;
+        string? nickname = null;
+        string? profileImage = null;
+
+        if (doc.TryGetProperty("kakao_account", out var account) && account.ValueKind == JsonValueKind.Object)
+        {
+            if (account.TryGetProperty("email", out var emailProp))
+            {
+                email = emailProp.GetString();
+            }
+
+            if (account.TryGetProperty("is_email_verified", out var verifiedProp))
+            {
+                emailVerified = verifiedProp.ValueKind == JsonValueKind.True;
+            }
+
+            if (account.TryGetProperty("profile", out var profile) && profile.ValueKind == JsonValueKind.Object)
+            {
+                nickname = profile.TryGetProperty("nickname", out var nickProp) ? nickProp.GetString() : null;
+                profileImage = profile.TryGetProperty("profile_image_url", out var imgProp) ? imgProp.GetString() : null;
+            }
+        }
+
+        return new SocialUserInfo(id, email, email is not null && emailVerified, nickname, profileImage);
+    }
+}

+ 184 - 0
Infrastructure/Authentication/NaverAuthProvider.cs

@@ -0,0 +1,184 @@
+using System.Text.Json;
+using Application.Abstractions.Authentication;
+using Microsoft.Extensions.Logging;
+
+namespace Infrastructure.Authentication;
+
+/// <summary>
+/// 네이버 로그인 (OAuth 2.0 authorization code). OIDC(id_token) 미지원이라 code → 토큰 → 프로필 API 경로 사용.
+/// state 는 스펙상 필수 (CSRF 방어) — 호출자(핸들러)가 검증하고 토큰 교환에도 전달한다.
+/// email 은 "연락처 이메일"이라 타사 주소 가능 + 검증 플래그 없음 → EmailVerified 항상 false (d5 §③).
+/// </summary>
+public sealed class NaverAuthProvider(
+    HttpClient httpClient,
+    ILogger<NaverAuthProvider> logger
+) : ISocialAuthProvider
+{
+    public const string ProviderName = "Naver";
+
+    private const string AuthEndpoint = "https://nid.naver.com/oauth2.0/authorize";
+    private const string TokenEndpoint = "https://nid.naver.com/oauth2.0/token";
+    private const string UserInfoEndpoint = "https://openapi.naver.com/v1/nid/me";
+
+    public string Provider => ProviderName;
+
+    public string GetAuthorizationUrl(string clientId, string redirectUri, string state)
+    {
+        var parameters = new Dictionary<string, string>
+        {
+            ["response_type"] = "code",
+            ["client_id"] = clientId,
+            ["redirect_uri"] = redirectUri,
+            ["state"] = state
+        };
+
+        var query = string.Join("&", parameters.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
+
+        return $"{AuthEndpoint}?{query}";
+    }
+
+    public async Task<SocialTokens?> ExchangeCodeAsync(string code, string redirectUri, string? state, string clientId, string? clientSecret, CancellationToken ct)
+    {
+        try
+        {
+            var form = new Dictionary<string, string>
+            {
+                ["grant_type"] = "authorization_code",
+                ["client_id"] = clientId,
+                ["client_secret"] = clientSecret ?? string.Empty,
+                ["code"] = code,
+                ["redirect_uri"] = redirectUri
+            };
+
+            if (!string.IsNullOrWhiteSpace(state))
+            {
+                form["state"] = state;
+            }
+
+            var response = await httpClient.PostAsync(TokenEndpoint, new FormUrlEncodedContent(form), ct);
+            var json = await response.Content.ReadAsStringAsync(ct);
+
+            // 네이버 토큰 엔드포인트는 오류도 200 으로 내려줄 수 있음 (error 필드) → 파서가 함께 거른다
+            if (!response.IsSuccessStatusCode)
+            {
+                logger.LogWarning("Naver OAuth code exchange failed: {StatusCode} {Body}", response.StatusCode, json);
+                return null;
+            }
+
+            var tokens = ParseTokens(json);
+
+            if (tokens is null)
+            {
+                logger.LogWarning("Naver OAuth token response invalid: {Body}", json);
+            }
+
+            return tokens;
+        }
+        catch (Exception ex)
+        {
+            logger.LogError(ex, "Naver OAuth code exchange error");
+            return null;
+        }
+    }
+
+    public async Task<SocialUserInfo?> GetUserInfoAsync(string accessToken, CancellationToken ct)
+    {
+        try
+        {
+            using var request = new HttpRequestMessage(HttpMethod.Get, UserInfoEndpoint);
+            request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
+
+            var response = await httpClient.SendAsync(request, ct);
+            var json = await response.Content.ReadAsStringAsync(ct);
+
+            if (!response.IsSuccessStatusCode)
+            {
+                logger.LogWarning("Naver user info failed: {StatusCode} {Body}", response.StatusCode, json);
+                return null;
+            }
+
+            var userInfo = ParseUserInfo(json);
+
+            if (userInfo is null)
+            {
+                logger.LogWarning("Naver user info response invalid: {Body}", json);
+            }
+
+            return userInfo;
+        }
+        catch (Exception ex)
+        {
+            logger.LogError(ex, "Naver user info error");
+            return null;
+        }
+    }
+
+    /// <summary>토큰 응답 파싱 — 네이버는 expires_in 을 문자열("3600")로 내려준다</summary>
+    public static SocialTokens? ParseTokens(string json)
+    {
+        var doc = JsonSerializer.Deserialize<JsonElement>(json);
+
+        if (doc.ValueKind != JsonValueKind.Object || doc.TryGetProperty("error", out _))
+        {
+            return null;
+        }
+
+        if (!doc.TryGetProperty("access_token", out var accessTokenProp) || accessTokenProp.GetString() is not { Length: > 0 } accessToken)
+        {
+            return null;
+        }
+
+        var refreshToken = doc.TryGetProperty("refresh_token", out var rtProp) ? rtProp.GetString() : null;
+        var expiresIn = ReadExpiresIn(doc);
+
+        return new SocialTokens(
+            accessToken,
+            refreshToken,
+            DateTime.UtcNow.AddSeconds(expiresIn - 60), // 60초 여유
+            []
+        );
+    }
+
+    /// <summary>프로필 응답 파싱 — resultcode "00" 외에는 실패. id 는 앱 단위 유니크 문자열(≤64자)</summary>
+    public static SocialUserInfo? ParseUserInfo(string json)
+    {
+        var doc = JsonSerializer.Deserialize<JsonElement>(json);
+
+        if (doc.ValueKind != JsonValueKind.Object || !doc.TryGetProperty("resultcode", out var resultCodeProp) || resultCodeProp.GetString() != "00")
+        {
+            return null;
+        }
+
+        if (!doc.TryGetProperty("response", out var res) || res.ValueKind != JsonValueKind.Object)
+        {
+            return null;
+        }
+
+        if (!res.TryGetProperty("id", out var idProp) || idProp.GetString() is not { Length: > 0 } id)
+        {
+            return null;
+        }
+
+        var email = res.TryGetProperty("email", out var emailProp) ? emailProp.GetString() : null;
+        var nickname = res.TryGetProperty("nickname", out var nickProp) ? nickProp.GetString() : null;
+        var profileImage = res.TryGetProperty("profile_image", out var imgProp) ? imgProp.GetString() : null;
+
+        // 네이버 email 은 연락처 이메일 (타사 주소 가능, 검증 플래그 없음) → 항상 미검증 취급
+        return new SocialUserInfo(id, email, EmailVerified: false, nickname, profileImage);
+    }
+
+    internal static int ReadExpiresIn(JsonElement doc)
+    {
+        if (!doc.TryGetProperty("expires_in", out var expProp))
+        {
+            return 3600;
+        }
+
+        return expProp.ValueKind switch
+        {
+            JsonValueKind.Number => expProp.GetInt32(),
+            JsonValueKind.String when int.TryParse(expProp.GetString(), out var parsed) => parsed,
+            _ => 3600
+        };
+    }
+}

+ 6 - 0
Infrastructure/DependencyInjection.cs

@@ -123,6 +123,12 @@ public static class DependencyInjection
         services.AddScoped<IBoardPermissionService, BoardPermissionService>();
         services.AddHttpClient<IGoogleTokenValidator, GoogleTokenValidator>();
 
+        // 소셜 로그인 provider (개미투자 D5 — Naver/Kakao authorization code). 핸들러가 IEnumerable 에서 이름으로 해석
+        services.AddHttpClient<NaverAuthProvider>();
+        services.AddHttpClient<KakaoAuthProvider>();
+        services.AddTransient<ISocialAuthProvider>(sp => sp.GetRequiredService<NaverAuthProvider>());
+        services.AddTransient<ISocialAuthProvider>(sp => sp.GetRequiredService<KakaoAuthProvider>());
+
         // YouTube & Google OAuth (HttpClients + singletons — Admin에서도 "API 키 테스트" 등에 쓰므로 공용)
         services.AddSingleton<IYouTubeApiKeyProvider, YouTubeApiKeyProvider>();
         services.AddTransient<YouTubeApiKeyHandler>();

+ 23 - 0
SharedKernel/AppSetting.cs

@@ -14,6 +14,7 @@ public sealed class AppSettings
     public EncryptionSection Encryption { get; init; } = new();
     public FeaturesSection Features { get; init; } = new();
     public StockDataSection StockData { get; init; } = new();
+    public OAuthSection OAuth { get; init; } = new();
 
     public sealed class AppSection
     {
@@ -112,6 +113,28 @@ public sealed class AppSettings
         public Dictionary<string, string> Keys { get; init; } = new();
     }
 
+    /// <summary>
+    /// 소셜 로그인 OAuth 설정 (개미투자 D5 — Naver/Kakao). 키는 각 개발자센터 발급 후 채움 (빈 값이면 해당 provider 로그인 비활성).
+    /// Google 은 DB Config(External.GoogleClientId) 현행 유지 — 여기 포함하지 않는다.
+    /// </summary>
+    public class OAuthSection
+    {
+        public OAuthProviderSection Naver { get; init; } = new();
+        public OAuthProviderSection Kakao { get; init; } = new();
+
+        public class OAuthProviderSection
+        {
+            /// <summary>네이버: Client ID / 카카오: REST API 키</summary>
+            public string ClientId { get; init; } = string.Empty;
+
+            /// <summary>네이버: Client Secret(필수) / 카카오: 콘솔에서 활성화한 경우만 (빈 값이면 미전송)</summary>
+            public string ClientSecret { get; init; } = string.Empty;
+
+            /// <summary>기본 redirect URI — 요청에서 미전달 시 사용. 개발자센터에 등록한 값과 일치해야 함</summary>
+            public string RedirectUri { get; init; } = string.Empty;
+        }
+    }
+
     /// <summary>
     /// 주식 데이터 수집 배치 설정 (개미투자 D1). 플래그 기본 false — API 키 발급/운영 결정 후 활성화.
     /// Features:Channel 게이트와 무관하게 별도 등록 (AddStockDataServices).

+ 82 - 0
Tests/Application.Tests/SocialAuthProviderParserTests.cs

@@ -0,0 +1,82 @@
+using Infrastructure.Authentication;
+
+namespace Application.Tests;
+
+[TestClass]
+public sealed class SocialAuthProviderParserTests
+{
+    [TestMethod]
+    public void Naver_ParseUserInfo_MapsFields_EmailAlwaysUnverified()
+    {
+        // 네이버 프로필 표준 응답 — email 은 연락처 이메일이라 항상 EmailVerified=false 로 매핑되어야 한다 (d5 §③)
+        const string json = """
+        {"resultcode":"00","message":"success","response":{"id":"32742776","nickname":"개미","profile_image":"https://ssl.pstatic.net/img.png","email":"ant@example.com","name":"홍길동"}}
+        """;
+
+        var info = NaverAuthProvider.ParseUserInfo(json);
+
+        Assert.IsNotNull(info);
+        Assert.AreEqual("32742776", info.ProviderUserKey);
+        Assert.AreEqual("ant@example.com", info.Email);
+        Assert.IsFalse(info.EmailVerified, "네이버 이메일은 검증 플래그가 없어 항상 미검증 취급한다");
+        Assert.AreEqual("개미", info.Nickname);
+        Assert.AreEqual("https://ssl.pstatic.net/img.png", info.ProfileImageUrl);
+
+        // resultcode 00 외에는 실패
+        Assert.IsNull(NaverAuthProvider.ParseUserInfo("""{"resultcode":"024","message":"Authentication failed"}"""));
+    }
+
+    [TestMethod]
+    public void Naver_ParseTokens_StringExpiresIn_And_ErrorBody()
+    {
+        // 네이버 토큰 응답은 expires_in 이 문자열("3600")로 온다 + 오류가 200 으로 내려오는 케이스(error 필드)도 걸러야 한다
+        const string json = """
+        {"access_token":"AAAAQosjWDJieBiQZc3to9YQp6HDLNzYyBYyO8","refresh_token":"c8ceMEJisO4Se7uGisHoX0f5JEii7JnipglQipkOn5Zp3tyP7dHQoP0zNKHUq2gY","token_type":"bearer","expires_in":"3600"}
+        """;
+
+        var before = DateTime.UtcNow;
+        var tokens = NaverAuthProvider.ParseTokens(json);
+
+        Assert.IsNotNull(tokens);
+        Assert.AreEqual("AAAAQosjWDJieBiQZc3to9YQp6HDLNzYyBYyO8", tokens.AccessToken);
+        Assert.IsNotNull(tokens.RefreshToken);
+        Assert.IsTrue(tokens.ExpiresAt > before.AddSeconds(3000) && tokens.ExpiresAt <= before.AddSeconds(3600), "문자열 expires_in(3600)이 파싱되어야 한다");
+
+        Assert.IsNull(NaverAuthProvider.ParseTokens("""{"error":"invalid_request","error_description":"no valid data in session"}"""), "error 필드가 있으면 실패로 처리한다");
+    }
+
+    [TestMethod]
+    public void Kakao_ParseUserInfo_VerifiedEmail_MapsFields()
+    {
+        // 카카오 v2/user/me — id 는 int64, 검증된 이메일은 EmailVerified=true 로 매핑
+        const string json = """
+        {"id":123456789,"connected_at":"2026-07-03T01:00:00Z","kakao_account":{"profile_nickname_needs_agreement":false,"profile":{"nickname":"개미왕","thumbnail_image_url":"https://k.kakaocdn.net/thumb.jpg","profile_image_url":"https://k.kakaocdn.net/profile.jpg"},"has_email":true,"email_needs_agreement":false,"is_email_valid":true,"is_email_verified":true,"email":"ant@kakao.com"}}
+        """;
+
+        var info = KakaoAuthProvider.ParseUserInfo(json);
+
+        Assert.IsNotNull(info);
+        Assert.AreEqual("123456789", info.ProviderUserKey, "int64 id 는 문자열화하여 ProviderUserKey 로 쓴다");
+        Assert.AreEqual("ant@kakao.com", info.Email);
+        Assert.IsTrue(info.EmailVerified);
+        Assert.AreEqual("개미왕", info.Nickname);
+        Assert.AreEqual("https://k.kakaocdn.net/profile.jpg", info.ProfileImageUrl);
+    }
+
+    [TestMethod]
+    public void Kakao_ParseUserInfo_EmailNotAgreed_ReturnsNullEmail()
+    {
+        // 카카오 account_email 은 선택동의 — 미동의 시 email 부재 → Email null (핸들러가 Auth.EmailRequired 로 분기)
+        const string json = """
+        {"id":987654321,"kakao_account":{"profile":{"nickname":"미동의개미"},"has_email":true,"email_needs_agreement":true}}
+        """;
+
+        var info = KakaoAuthProvider.ParseUserInfo(json);
+
+        Assert.IsNotNull(info);
+        Assert.AreEqual("987654321", info.ProviderUserKey);
+        Assert.IsNull(info.Email, "이메일 미동의 시 null 이어야 한다");
+        Assert.IsFalse(info.EmailVerified);
+        Assert.AreEqual("미동의개미", info.Nickname);
+    }
+}

+ 147 - 0
Tests/Application.Tests/SocialLoginHandlerTests.cs

@@ -0,0 +1,147 @@
+using Application.Abstractions.Authentication;
+using Application.Abstractions.Cache;
+using Application.Abstractions.Crypto;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using SharedKernel;
+using SocialLoginCommand = Application.Features.Api.Auth.SocialLogin.Command;
+using SocialLoginHandler = Application.Features.Api.Auth.SocialLogin.Handler;
+
+namespace Application.Tests;
+
+[TestClass]
+public sealed class SocialLoginHandlerTests
+{
+    private sealed class FakeSocialAuthProvider(string provider, SocialUserInfo? userInfo) : ISocialAuthProvider
+    {
+        public string Provider => provider;
+
+        public string GetAuthorizationUrl(string clientId, string redirectUri, string state) => $"https://fake/{provider}?state={state}";
+
+        public Task<SocialTokens?> ExchangeCodeAsync(string code, string redirectUri, string? state, string clientId, string? clientSecret, CancellationToken ct)
+        {
+            return Task.FromResult<SocialTokens?>(new SocialTokens("fake-access-token", "fake-refresh-token", DateTime.UtcNow.AddHours(1), ["account_email"]));
+        }
+
+        public Task<SocialUserInfo?> GetUserInfoAsync(string accessToken, CancellationToken ct) => Task.FromResult(userInfo);
+    }
+
+    private sealed class FakeJwtTokenProvider : IJwtTokenProvider
+    {
+        public string CreateAccessToken(int memberID, string email, string? name) => $"jwt-{memberID}";
+        public string CreateRefreshToken() => $"rt-{Guid.NewGuid():N}";
+        public string CreateOAuth2AccessToken(int applicationID, string clientID, int ownerMemberID, IEnumerable<string> scopes, TimeSpan ttl) => "oauth2";
+    }
+
+    private sealed class FakeFieldEncryptor : IFieldEncryptor
+    {
+        public int CurrentKeyVersion => 1;
+        public string Encrypt(string plaintext) => $"enc:{plaintext}";
+        public string Decrypt(string ciphertextBase64, int keyVersion) => ciphertextBase64.StartsWith("enc:") ? ciphertextBase64[4..] : ciphertextBase64;
+        public string Hash(string plaintext) => plaintext;
+    }
+
+    private sealed class FakeCacheService : ICacheService
+    {
+        public Task<T?> GetAsync<T>(string key, CancellationToken ct = default) => Task.FromResult<T?>(default);
+        public Task SetAsync<T>(string key, T value, CancellationToken ct = default) => Task.CompletedTask;
+        public Task SetAsync<T>(string key, T value, TimeSpan expiry, CancellationToken ct = default) => Task.CompletedTask;
+        public Task RemoveAsync(string key, CancellationToken ct = default) => Task.CompletedTask;
+        public Task RemoveByPrefixAsync(string prefix, CancellationToken ct = default) => Task.CompletedTask;
+        public Task<bool> TryLockAsync(string key, TimeSpan expiry, CancellationToken ct = default) => Task.FromResult(true);
+    }
+
+    private static SocialLoginHandler CreateHandler(Infrastructure.Persistence.AppDbContext db, SocialUserInfo? userInfo, string provider = "Kakao")
+    {
+        var options = Options.Create(new AppSettings
+        {
+            JWT = new AppSettings.JwtSection
+            {
+                AccessTokenExpiration = 30,
+                RefreshTokenExpiration = 7
+            },
+            OAuth = new AppSettings.OAuthSection
+            {
+                Naver = new AppSettings.OAuthSection.OAuthProviderSection
+                {
+                    ClientId = "naver-client",
+                    ClientSecret = "naver-secret",
+                    RedirectUri = "https://localhost:3000/login/naver/callback"
+                },
+                Kakao = new AppSettings.OAuthSection.OAuthProviderSection
+                {
+                    ClientId = "kakao-rest-key",
+                    ClientSecret = "",
+                    RedirectUri = "https://localhost:3000/login/kakao/callback"
+                }
+            }
+        });
+
+        return new SocialLoginHandler(
+            db,
+            new FakeJwtTokenProvider(),
+            [new FakeSocialAuthProvider(provider, userInfo)],
+            new FakeFieldEncryptor(),
+            options,
+            NullLogger<SocialLoginHandler>.Instance,
+            new FakeCacheService()
+        );
+    }
+
+    [TestMethod]
+    public async Task KakaoEmailNotProvided_Fails_EmailRequired()
+    {
+        // 기대 행위: 카카오 이메일 선택동의 미동의(Email null) 시 Auth.EmailRequired 로 실패해
+        // 프론트가 이메일 보완 화면으로 유도할 수 있어야 한다 (d5 §③ 정책 4).
+        using var db = TestDb.Create();
+        var handler = CreateHandler(db, new SocialUserInfo("987654321", null, false, "미동의개미", null));
+
+        var result = await handler.Handle(new SocialLoginCommand("kakao", "auth-code"), default);
+
+        Assert.IsFalse(result.IsSuccess);
+        Assert.AreEqual("Auth.EmailRequired", result.Error.Code);
+    }
+
+    [TestMethod]
+    public async Task ExistingMemberWithoutLink_Fails_AccountExists_NoAutoLink()
+    {
+        // 기대 행위: 같은 이메일의 기존 계정이 있어도 자동 연결하지 않고 Auth.AccountExists 로 실패한다 (d0 §5-⑦ 자동 계정연결 금지).
+        using var db = TestDb.Create();
+        var memberID = await TestDb.CreateMemberAsync(db);
+        var email = (await db.Member.SingleAsync(c => c.ID == memberID)).Email;
+        var handler = CreateHandler(db, new SocialUserInfo("11122233", email, true, "개미", null));
+
+        var result = await handler.Handle(new SocialLoginCommand("kakao", "auth-code"), default);
+
+        Assert.IsFalse(result.IsSuccess);
+        Assert.AreEqual("Auth.AccountExists", result.Error.Code);
+
+        using var verify = TestDb.Create();
+        Assert.IsFalse(await verify.MemberOAuthToken.AnyAsync(c => c.MemberID == memberID), "자동 계정연결이 일어나면 안 된다");
+    }
+
+    [TestMethod]
+    public async Task NewEmail_AutoSignup_CreatesMemberAndOAuthLink()
+    {
+        // 기대 행위: 미가입 이메일은 자동가입 + MemberOAuthToken 생성(암호화 저장) 후 JWT 를 발급한다.
+        // 검증된 이메일(카카오 is_email_verified)만 이메일 인증 완료 처리한다.
+        using var db = TestDb.Create();
+        var email = $"social-{Guid.NewGuid():N}@antooza.test";
+        var handler = CreateHandler(db, new SocialUserInfo("55566677", email, true, "신규개미", null));
+
+        var result = await handler.Handle(new SocialLoginCommand("kakao", "auth-code", null, null, "127.0.0.1", "test-agent"), default);
+
+        Assert.IsTrue(result.IsSuccess, $"자동가입 로그인이 성공해야 한다: {result.Error?.Code}");
+        Assert.IsFalse(string.IsNullOrEmpty(result.Value.AccessToken));
+
+        using var verify = TestDb.Create();
+        var member = await verify.Member.SingleAsync(c => c.Email == email);
+        Assert.IsTrue(member.IsEmailVerified, "검증된 이메일은 인증 완료 처리된다");
+
+        var link = await verify.MemberOAuthToken.SingleAsync(c => c.MemberID == member.ID);
+        Assert.AreEqual("Kakao", link.Provider);
+        Assert.AreEqual("enc:fake-access-token", link.AccessTokenEnc, "신규 저장분은 IFieldEncryptor 로 암호화되어야 한다");
+        Assert.IsTrue(await verify.RefreshToken.AnyAsync(c => c.MemberID == member.ID), "refresh token 이 저장되어야 한다");
+    }
+}

+ 27 - 0
Tests/Application.Tests/StockQueryHandlerTests.cs

@@ -1,6 +1,8 @@
 using Domain.Entities.Stocks;
 using Domain.Entities.Stocks.ValueObject;
 using Infrastructure.Persistence;
+using ListHandler = Application.Features.Api.Stocks.GetList.Handler;
+using ListQuery = Application.Features.Api.Stocks.GetList.Query;
 using QuotesHandler = Application.Features.Api.Stocks.GetQuotes.Handler;
 using QuotesQuery = Application.Features.Api.Stocks.GetQuotes.Query;
 using SuggestHandler = Application.Features.Api.Stocks.Suggest.Handler;
@@ -96,6 +98,31 @@ public sealed class StockQueryHandlerTests
         Assert.IsNull(noPrice.ClosePrice, "시세 미수집 종목은 null 로 반환된다");
     }
 
+    [TestMethod]
+    public async Task List_ReturnsPrevDayVolume_FromLatestDailyPrice()
+    {
+        // 기대 행위: 목록의 Volume 은 최근 거래일(LastTradingDate) StockDailyPrice 의 거래량이고, 시세 미수집 종목은 null 이다.
+        using var db = TestDb.Create();
+        var stock = await CreateStockAsync(db, "940001", "YVL거래량종목");
+        stock.UpdateLastPrice(new DateOnly(2026, 7, 2), 70500, 1.25m, 420_000_000_000L);
+        await db.StockDailyPrice.AddAsync(StockDailyPrice.Create(stock.ID, new DateOnly(2026, 7, 1), 69000, 70000, 68500, 69500, 11_000_000L, 760_000_000_000L, 500, 0.72m));
+        await db.StockDailyPrice.AddAsync(StockDailyPrice.Create(stock.ID, new DateOnly(2026, 7, 2), 70000, 71000, 69800, 70500, 12_345_678L, 870_000_000_000L, 1000, 1.25m));
+        await CreateStockAsync(db, "940002", "YVL무시세종목");
+        await db.SaveChangesAsync(default);
+
+        var handler = new ListHandler(db);
+
+        var result = await handler.Handle(new ListQuery("YVL", null, 1, 20), default);
+
+        Assert.AreEqual(2, result.Total);
+
+        var withVolume = result.List.Single(c => c.Code == "940001");
+        Assert.AreEqual(12_345_678L, withVolume.Volume, "최근 거래일의 거래량이 반환되어야 한다");
+
+        var noPrice = result.List.Single(c => c.Code == "940002");
+        Assert.IsNull(noPrice.Volume, "시세 미수집 종목은 null 로 반환된다");
+    }
+
     [TestMethod]
     public async Task Quotes_Over50Codes_Fails()
     {

+ 38 - 0
Web.Api/Endpoints/Auth/SocialAuthorizeUrl.cs

@@ -0,0 +1,38 @@
+using Application.Abstractions.Messaging;
+using Web.Api.Common;
+using Web.Api.Extensions;
+
+namespace Web.Api.Endpoints.Auth;
+
+/// <summary>
+/// 소셜 로그인 시작 URL 생성 (Naver/Kakao) — 프론트 start 라우트가 state 생성/쿠키 보관 후 호출.
+/// redirectUri 미전달 시 AppSettings OAuth:{Provider}:RedirectUri 사용.
+/// </summary>
+internal sealed class SocialAuthorizeUrl : IEndpoint
+{
+    public void MapEndpoint(IEndpointRouteBuilder app)
+    {
+        app.MapGet("api/auth/{provider}/authorize-url", async (
+            string provider,
+            ISender sender,
+            string? state = null,
+            string? redirectUri = null,
+            CancellationToken ct = default
+        ) => {
+            var query = new Application.Features.Api.Auth.SocialAuthorizeUrl.Query(
+                provider,
+                state ?? string.Empty,
+                redirectUri
+            );
+
+            var result = await sender.Send(query, ct);
+
+            return result.Match(
+                data => ApiResponse.Ok(data),
+                CustomResults.Problem
+            );
+        })
+        .WithTags("Auth")
+        .AllowAnonymous();
+    }
+}

+ 52 - 0
Web.Api/Endpoints/Auth/SocialLogin.cs

@@ -0,0 +1,52 @@
+using SharedKernel.Extensions;
+using Application.Abstractions.Messaging;
+using Web.Api.Extensions;
+using Web.Api.Common;
+
+namespace Web.Api.Endpoints.Auth;
+
+/// <summary>
+/// 소셜 로그인 (Naver/Kakao) — 프론트 콜백 라우트가 code/state 를 받아 호출 (d5 §④).
+/// 이메일 미제공(카카오 선택동의)은 Auth.EmailRequired, 미연동 기존 계정은 Auth.AccountExists 로 실패.
+/// </summary>
+internal sealed class SocialLogin : IEndpoint
+{
+    public sealed record Request(
+        string Provider,
+        string Code,
+        string? State,
+        string? RedirectUri
+    );
+
+    public void MapEndpoint(IEndpointRouteBuilder app)
+    {
+        app.MapPost("api/auth/social-login", async (
+            Request request,
+            HttpContext httpContext,
+            ISender sender,
+            CancellationToken ct
+        ) => {
+
+            var ipAddress = httpContext.GetClientIP();
+            var userAgent = httpContext.GetUserAgent();
+
+            var command = new Application.Features.Api.Auth.SocialLogin.Command(
+                request.Provider,
+                request.Code,
+                request.State,
+                request.RedirectUri,
+                ipAddress,
+                userAgent
+            );
+
+            var result = await sender.Send(command, ct);
+
+            return result.Match(
+                data => ApiResponse.Ok(data),
+                CustomResults.Problem
+            );
+        })
+        .WithTags("Auth")
+        .AllowAnonymous();
+    }
+}