Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions ProjectVG.Api/ApiServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ public static IServiceCollection AddApiServices(this IServiceCollection services

/// <summary>
/// 인증 및 인가 서비스
/// <summary>
/// Negotiate(Windows) 인증 스킴을 추가하고 전역 대체 권한 정책(FallbackPolicy)을 제거하여 애플리케이션의 인증/인가를 구성합니다.
/// </summary>
/// <returns>구성된 IServiceCollection 인스턴스.</returns>
public static IServiceCollection AddApiAuthentication(this IServiceCollection services)
{
services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
Expand All @@ -49,7 +52,10 @@ public static IServiceCollection AddApiAuthentication(this IServiceCollection se

/// <summary>
/// OAuth2 인증 서비스 (선택적)
/// <summary>
/// 쿠키 인증을 기본 인증 방식으로 설정하여 OAuth2 흐름을 별도 컨트롤러에서 처리할 수 있도록 구성합니다.
/// </summary>
/// <returns>구성된 IServiceCollection을 반환합니다.</returns>
public static IServiceCollection AddOAuth2Authentication(this IServiceCollection services)
{
// OAuth2는 별도 컨트롤러에서 처리하므로 기본 인증만 설정
Expand All @@ -64,7 +70,12 @@ public static IServiceCollection AddOAuth2Authentication(this IServiceCollection

/// <summary>
/// 개발용 CORS 정책
/// <summary>
/// 개발 환경에서 사용하도록 모든 출처, 모든 HTTP 메서드 및 모든 헤더를 허용하고
/// "X-Access-Token", "X-Refresh-Token", "X-Expires-In", "X-UID" 응답 헤더를 노출하는
/// "AllowAll" CORS 정책을 DI 컨테이너에 등록합니다.
/// </summary>
/// <returns>구성된 IServiceCollection을 반환합니다.</returns>
public static IServiceCollection AddDevelopmentCors(this IServiceCollection services)
{
services.AddCors(options => {
Expand Down
28 changes: 28 additions & 0 deletions ProjectVG.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,22 @@ public class AuthController : ControllerBase
{
private readonly IAuthService _authService;

/// <summary>
/// 컨트롤러에 인증 서비스 의존성을 주입하여 초기화합니다.
/// </summary>
public AuthController(IAuthService authService)
{
_authService = authService;
}

/// <summary>
/// 요청 헤더의 리프레시 토큰으로 액세스/리프레시 토큰을 갱신하고 사용자 정보를 반환합니다.
/// </summary>
/// <remarks>
/// 요청 헤더 "X-Refresh-Token"에서 리프레시 토큰을 읽어 IAuthService.RefreshTokenAsync를 호출합니다.
/// 응답은 { success = true, tokens = ..., user = ... } 형태의 200 OK 입니다.
/// </remarks>
/// <returns>갱신된 토큰과 사용자 정보를 포함한 200 OK IActionResult.</returns>
[HttpPost("refresh")]
public async Task<IActionResult> RefreshToken()
{
Expand All @@ -28,6 +39,13 @@ public async Task<IActionResult> RefreshToken()
});
}

/// <summary>
/// 요청 헤더의 "X-Refresh-Token"에서 리프레시 토큰을 읽어 해당 토큰의 로그아웃(무효화)을 수행하고 결과를 반환합니다.
/// </summary>
/// <returns>
/// HTTP 200 응답을 반환합니다. 본문은 익명 객체로 { success: bool, message: string } 형태이며,
/// success는 로그아웃 성공 여부, message는 "Logout successful" 또는 "Logout failed"입니다.
/// </returns>
[HttpPost("logout")]
public async Task<IActionResult> Logout()
{
Expand All @@ -41,6 +59,12 @@ public async Task<IActionResult> Logout()
});
}

/// <summary>
/// 게스트 식별자(guestId)를 사용해 게스트 OAuth 로그인으로 사용자와 토큰을 발급하여 반환합니다.
/// </summary>
/// <param name="guestId">클라이언트에서 전달된 게스트 고유 식별자(빈 값이면 유효하지 않음).</param>
/// <returns>성공 시 HTTP 200 응답으로 { success = true, tokens, user } 형태의 페이로드를 반환합니다.</returns>
/// <exception cref="ValidationException">guestId가 null 또는 빈 문자열인 경우 ErrorCode.GUEST_ID_INVALID와 함께 던져집니다.</exception>
[HttpPost("guest-login")]
public async Task<IActionResult> GuestLogin([FromBody] string guestId)
{
Expand All @@ -59,6 +83,10 @@ public async Task<IActionResult> GuestLogin([FromBody] string guestId)
});
}

/// <summary>
/// HTTP 요청 헤더 "X-Refresh-Token"에서 리프레시 토큰을 읽어 반환합니다.
/// </summary>
/// <returns>헤더에 지정된 첫 번째 토큰 값 또는 헤더가 없을 경우 빈 문자열.</returns>
private string GetRefreshTokenFromHeader()
{
return Request.Headers["X-Refresh-Token"].FirstOrDefault() ?? string.Empty;
Expand Down
9 changes: 9 additions & 0 deletions ProjectVG.Api/Controllers/ChatController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,20 @@ public class ChatController : ControllerBase
{
private readonly IChatService _chatService;

/// <summary>
/// IChatService를 주입 받아 컨트롤러의 채팅 서비스 의존성을 설정합니다.
/// </summary>
public ChatController(IChatService chatService)
{
_chatService = chatService;
}

/// <summary>
/// 현재 인증된 사용자로부터 채팅 요청을 받아 채팅 처리를 큐에 등록하고 결과를 반환합니다.
/// </summary>
/// <param name="request">클라이언트가 보낸 채팅 요청 객체 (Message, CharacterId 포함).</param>
/// <returns>큐에 등록된 작업의 결과를 포함한 HTTP 200 응답(IActionResult).</returns>
/// <exception cref="ValidationException">사용자 식별자(ClaimTypes.NameIdentifier)가 없거나 GUID로 파싱할 수 없을 경우 발생하며, ErrorCode.AUTHENTICATION_FAILED를 포함합니다.</exception>
[HttpPost]
[JwtAuthentication]
public async Task<IActionResult> ProcessChat([FromBody] ChatRequest request)
Expand Down
48 changes: 48 additions & 0 deletions ProjectVG.Api/Controllers/OAuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ public class OAuthController : ControllerBase
private readonly IOAuth2Service _oauth2Service;
private readonly IOAuth2ProviderFactory _providerFactory;

/// <summary>
/// OAuth2 관련 서비스와 공급자 팩토리를 사용하도록 컨트롤러를 초기화합니다.
/// </summary>
/// <remarks>
/// 생성자에서 전달된 `IOptions&lt;OAuth2ProviderSettings&gt; oauth2Settings` 값은 현재 필드로 저장되거나 사용되지 않습니다.
/// </remarks>
public OAuthController(
IOAuth2Service oauth2Service,
IOAuth2ProviderFactory providerFactory,
Expand All @@ -21,6 +27,10 @@ public OAuthController(
_providerFactory = providerFactory;
}

/// <summary>
/// 지원되는 OAuth2 제공자 목록을 조회하여 성공 여부와 함께 반환합니다.
/// </summary>
/// <returns>HTTP 200 응답으로 { success = true, providers = [...] } 형태의 JSON 결과를 포함한 IActionResult를 반환합니다.</returns>
[HttpGet("oauth2/providers")]
public IActionResult GetSupportedProviders()
{
Expand All @@ -32,6 +42,17 @@ public IActionResult GetSupportedProviders()
});
}

/// <summary>
/// 지정된 OAuth2 공급자에 대한 PKCE 검증을 수행하고, 공급자별 인증 URL을 생성하여 반환합니다.
/// </summary>
/// <param name="provider">요청할 OAuth2 공급자 이름. 지원되지 않는 공급자면 ValidationException(ErrorCode.OAUTH2_PROVIDER_NOT_SUPPORTED)이 발생합니다.</param>
/// <param name="state">클라이언트에서 전달한 상태 토큰(예: CSRF/상태 검증용 식별자).</param>
/// <param name="code_challenge">PKCE 코드 챌린지(필수). 비어 있으면 ValidationException(ErrorCode.OAUTH2_PKCE_INVALID)이 발생합니다.</param>
/// <param name="code_challenge_method">PKCE 메서드(현재 "S256"만 허용). 다른 값이면 ValidationException(ErrorCode.OAUTH2_PKCE_INVALID)이 발생합니다.</param>
/// <param name="code_verifier">옵션인 PKCE 코드 베리파이어(후속 흐름에서 사용될 수 있음).</param>
/// <param name="client_redirect_uri">클라이언트가 원하는 리디렉션 URI(옵션, 공급자별 처리).</param>
/// <returns>HTTP 200 응답으로 { success = true, provider, auth_url } 형태의 JSON을 반환합니다. auth_url은 클라이언트가 리디렉션해야 할 공급자 인증 URL입니다.</returns>
/// <exception cref="ValidationException">지원되지 않는 공급자 또는 PKCE 검증 실패 시 발생합니다. 사용되는 ErrorCode: OAUTH2_PROVIDER_NOT_SUPPORTED, OAUTH2_PKCE_INVALID.</exception>
[HttpGet("oauth2/authorize/{provider}")]
public async Task<IActionResult> OAuth2Authorize(
string provider,
Expand Down Expand Up @@ -62,6 +83,19 @@ public async Task<IActionResult> OAuth2Authorize(
});
}

/// <summary>
/// OAuth2 콜백 엔드포인트를 처리하고, 처리 결과의 리디렉션 URL로 리다이렉트합니다.
/// </summary>
/// <param name="provider">선택적 공급자 식별자(경로 매개변수). 제공되지 않아도 됩니다.</param>
/// <param name="code">OAuth2 공급자가 반환한 인증 코드(쿼리). 필수입니다.</param>
/// <param name="state">요청 시 전달된 상태 값(쿼리). 필수입니다.</param>
/// <param name="error">공급자가 반환한 오류 메시지(쿼리). 기본값은 null입니다. 존재하면 예외가 발생합니다.</param>
/// <returns>처리 결과의 RedirectUrl로 리다이렉트하는 <see cref="IActionResult"/>를 반환합니다.</returns>
/// <exception cref="ValidationException">
/// error 파라미터가 비어있지 않거나(code/state가 누락된 경우) 검증 실패 시 각각 다음 오류 코드를 발생시킵니다:
/// - OAUTH2_CALLBACK_FAILED: callback에서 error가 전달된 경우
/// - REQUIRED_PARAMETER_MISSING: code 또는 state가 누락된 경우
/// </exception>
[HttpGet("oauth2/callback")]
[HttpGet("oauth2/callback/{provider}")]
public async Task<IActionResult> OAuth2Callback(
Expand All @@ -85,6 +119,20 @@ public async Task<IActionResult> OAuth2Callback(
return Redirect(result.RedirectUrl!);
}

/// <summary>
/// 주어진 상태(state)에 대응하는 OAuth2 토큰 데이터를 조회하여 응답 헤더로 반환하고 해당 토큰 데이터를 삭제합니다.
/// </summary>
/// <remarks>
/// - 요청 쿼리의 <c>state</c>가 필수입니다.
/// - 조회한 토큰 데이터는 반환 직후 삭제됩니다.
/// - 반환 시 다음 헤더를 추가합니다: <c>X-Access-Token</c>, <c>X-Refresh-Token</c>, <c>X-Expires-In</c>, <c>X-UID</c>.
/// </remarks>
/// <param name="state">토큰 조회를 식별하는 상태 식별자(쿼리 파라미터). 빈값이면 예외가 발생합니다.</param>
/// <returns>요청이 성공하면 HTTP 200과 { success = true }를 반환합니다.</returns>
/// <exception cref="ValidationException">다음 상황에서 발생합니다:
/// - <see cref="ErrorCode.REQUIRED_PARAMETER_MISSING"/>: state가 비어있을 때.
/// - <see cref="ErrorCode.OAUTH2_REQUEST_NOT_FOUND"/>: 해당 state에 대한 토큰 데이터가 없을 때.
/// </exception>
[HttpGet("oauth2/token")]
public async Task<IActionResult> GetOAuth2Token([FromQuery] string state)
{
Expand Down
17 changes: 17 additions & 0 deletions ProjectVG.Api/Filters/JwtAuthenticationFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ namespace ProjectVG.Api.Filters
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class JwtAuthenticationAttribute : Attribute, IAsyncAuthorizationFilter
{
/// <summary>
/// 요청에서 Bearer JWT를 추출·검증하고 성공 시 HttpContext.User에 ClaimsPrincipal을 설정하여 인증을 적용합니다.
/// </summary>
/// <param name="context">현재 요청의 컨텍스트(요청 헤더에서 토큰을 읽고, HttpContext.User를 설정하는 데 사용).</param>
/// <returns>비동기 작업을 나타내는 Task.</returns>
/// <exception cref="AuthenticationException">다음 조건 중 하나일 때 발생:
/// <list type="bullet">
/// <item><description>ErrorCode.TOKEN_MISSING: 토큰이 헤더에 없거나 비어 있을 때.</description></item>
/// <item><description>ErrorCode.TOKEN_INVALID: 토큰이 유효하지 않을 때.</description></item>
/// <item><description>ErrorCode.AUTHENTICATION_FAILED: 토큰에서 사용자 ID를 얻을 수 없을 때.</description></item>
/// </list>
/// </exception>
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<JwtAuthenticationAttribute>>();
Expand Down Expand Up @@ -36,6 +48,11 @@ public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
logger.LogInformation("JWT 인증 성공 - 사용자: {UserId}", userId.Value);
}

/// <summary>
/// 요청 헤더들에서 Bearer 토큰을 찾아 토큰 문자열을 반환합니다.
/// </summary>
/// <param name="request">토큰을 추출할 HTTP 요청.</param>
/// <returns>헤더에서 찾은 토큰 문자열(접두사 "Bearer " 제거) 또는 찾지 못하면 null.</returns>
private string? ExtractToken(HttpRequest request)
{
var possibleHeaders = new[]
Expand Down
29 changes: 29 additions & 0 deletions ProjectVG.Api/Middleware/GlobalExceptionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ private async Task HandleExceptionAsync(HttpContext context, Exception exception
await context.Response.WriteAsync(jsonResponse);
}

/// <summary>
/// 전달된 예외를 적절한 전용 핸들러로 매핑하여 표준화된 ErrorResponse를 생성합니다.
/// </summary>
/// <remarks>
/// ValidationException, NotFoundException, AuthenticationException, ProjectVGException, ExternalServiceException,
/// DbUpdateException 등 알려진 예외는 각 전용 처리 메서드로 위임되고, 해당하지 않는 예외는 HandleGenericException에서 처리됩니다.
/// 반환되는 ErrorResponse는 클라이언트에 직렬화되어 HTTP 응답 페이로드로 사용됩니다.
/// </remarks>
private ErrorResponse CreateErrorResponse(Exception exception, HttpContext context)
{
if (exception is ValidationException validationEx) {
Expand Down Expand Up @@ -115,6 +123,15 @@ private ErrorResponse HandleValidationException(ValidationException exception, H
};
}

/// <summary>
/// NotFoundException을 ErrorResponse로 변환하여 반환합니다.
/// </summary>
/// <param name="exception">발생한 NotFoundException(내부에 ErrorCode, Message, StatusCode 포함).</param>
/// <param name="context">응답에 포함할 TraceIdentifier를 가져오기 위한 HttpContext.</param>
/// <returns>
/// 요청된 리소스를 찾을 수 없음을 나타내는 ErrorResponse:
/// ErrorCode, Message, StatusCode, UTC 타임스탬프, TraceId를 설정하여 반환합니다.
/// </returns>
private ErrorResponse HandleNotFoundException(NotFoundException exception, HttpContext context)
{
_logger.LogWarning(exception, "리소스를 찾을 수 없음: {ErrorCode} - {Message}", exception.ErrorCode.ToString(), exception.Message);
Expand All @@ -128,6 +145,12 @@ private ErrorResponse HandleNotFoundException(NotFoundException exception, HttpC
};
}

/// <summary>
/// 인증 관련 예외(AuthenticationException)를 표준화된 ErrorResponse로 변환하고 경고를 기록합니다.
/// </summary>
/// <param name="exception">처리할 AuthenticationException 인스턴스.</param>
/// <param name="context">현재 HTTP 요청의 HttpContext; 응답에 포함할 TraceIdentifier를 제공합니다.</param>
/// <returns>예외 정보를 기반으로 생성된 ErrorResponse(에러 코드, 메시지, 상태 코드, 타임스탬프, TraceId 포함).</returns>
private ErrorResponse HandleAuthenticationException(AuthenticationException exception, HttpContext context)
{
_logger.LogWarning(exception, "인증 실패: {ErrorCode} - {Message}", exception.ErrorCode.ToString(), exception.Message);
Expand All @@ -141,6 +164,12 @@ private ErrorResponse HandleAuthenticationException(AuthenticationException exce
};
}

/// <summary>
/// ProjectVG 전용 예외를 표준 ErrorResponse로 변환하여 반환합니다.
/// </summary>
/// <param name="exception">ErrorCode, Message, StatusCode 등을 포함한 ProjectVG 예외; 응답의 주요 필드 값으로 사용됩니다.</param>
/// <param name="context">응답에 포함할 TraceId를 가져오기 위해 사용되는 HttpContext.</param>
/// <returns>예외 정보를 매핑한 ErrorResponse(UTC 타임스탬프와 TraceId 포함).</returns>
private ErrorResponse HandleProjectVGException(ProjectVGException exception, HttpContext context)
{
_logger.LogWarning(exception, "ProjectVG 예외 발생: {ErrorCode} - {Message}", exception.ErrorCode.ToString(), exception.Message);
Expand Down
Loading