fix: 로그인 HTTP 자기호출 완전 제거 — IWorkspaceRepository 직접 DI 주입
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 9s
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 18s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
Deploy to Production / Build & Deploy to Production (push) Successful in 2m30s

이전 수정(34df08d)에서 localhost:5265로 고정했으나,
프로덕션 서버는 포트 5000으로 실행 중이어서 Connection refused 발생.

근본 원인: Razor 로그인 페이지가 자기 자신의 API를 HTTP로 재호출하는 구조.

해결:
- HttpClient 자기호출 완전 제거
- IWorkspaceRepository를 Razor 페이지에 직접 DI 주입
- DB 조회 → SHA-256 해시 검증 → 세션 발급 → 쿠키 설정을 인라인 처리
- 포트/프록시 의존성 완전 제거
This commit is contained in:
2026-07-06 17:38:46 +09:00
parent 8d72216959
commit d5ede69800
@@ -1,22 +1,26 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using QuantEngine.Core.Interfaces;
using QuantEngine.Core.Models;
namespace QuantEngine.Web.Pages.Account namespace QuantEngine.Web.Pages.Account
{ {
[AllowAnonymous] [AllowAnonymous]
public class LoginModel : PageModel public class LoginModel : PageModel
{ {
private readonly HttpClient _httpClient; private readonly IWorkspaceRepository _workspaceRepo;
private readonly ILogger<LoginModel> _logger; private readonly ILogger<LoginModel> _logger;
public string? Username { get; set; } public string? Username { get; set; }
public bool RememberUsername { get; set; } public bool RememberUsername { get; set; }
public string? ErrorMessage { get; set; } public string? ErrorMessage { get; set; }
public LoginModel(HttpClient httpClient, ILogger<LoginModel> logger) public LoginModel(IWorkspaceRepository workspaceRepo, ILogger<LoginModel> logger)
{ {
_httpClient = httpClient; _workspaceRepo = workspaceRepo;
_logger = logger; _logger = logger;
} }
@@ -41,51 +45,89 @@ namespace QuantEngine.Web.Pages.Account
try try
{ {
var loginRequest = new { Username = username, Password = password }; // Direct repository call — no internal HTTP round-trip.
// Always call the internal API directly to avoid Cloudflare proxy interference. // Using IWorkspaceRepository injected via DI avoids any port/proxy dependency.
// Request.Host / Request.Scheme must NOT be used here — they resolve to the external WorkspaceAccount? account = null;
// domain which causes the self-call to go out through Cloudflare and return 400. try
var requestUri = "http://localhost:5265/api/auth/login";
var response = await _httpClient.PostAsJsonAsync(requestUri, loginRequest);
if (response.IsSuccessStatusCode)
{ {
// Extract set-cookie header from API response and set it on proxy client response account = await _workspaceRepo.GetAccountByUsernameAsync(username.Trim());
if (response.Headers.TryGetValues("Set-Cookie", out var cookieHeaders))
{
foreach (var cookieHeader in cookieHeaders)
{
Response.Headers.Append("Set-Cookie", cookieHeader);
}
}
if (rememberUsername)
{
Response.Cookies.Append(
"quant_admin_username",
username,
new Microsoft.AspNetCore.Http.CookieOptions
{
Expires = DateTimeOffset.UtcNow.AddDays(30),
HttpOnly = false,
SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict
}
);
}
else
{
Response.Cookies.Delete("quant_admin_username");
}
return Redirect("/");
} }
else catch (Exception dbEx)
{
_logger.LogError(dbEx, "[Login] Database lookup failed for user '{Username}'", username);
ErrorMessage = "데이터베이스 연결 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.";
Username = username;
RememberUsername = rememberUsername;
return Page();
}
if (account is null || !string.Equals(account.IsActive, "true", StringComparison.OrdinalIgnoreCase))
{ {
ErrorMessage = "로그인 실패: 아이디 또는 비밀번호가 올바르지 않습니다."; ErrorMessage = "로그인 실패: 아이디 또는 비밀번호가 올바르지 않습니다.";
Username = username; Username = username;
RememberUsername = rememberUsername; RememberUsername = rememberUsername;
return Page(); return Page();
} }
var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));
if (!string.Equals(account.PasswordHash, passwordHash, StringComparison.OrdinalIgnoreCase))
{
ErrorMessage = "로그인 실패: 아이디 또는 비밀번호가 올바르지 않습니다.";
Username = username;
RememberUsername = rememberUsername;
return Page();
}
// Issue session token
var rawToken = Guid.NewGuid().ToString("N");
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(rawToken)));
var now = DateTimeOffset.UtcNow;
var expiresAt = now.AddDays(7);
await _workspaceRepo.UpsertSessionAsync(new WorkspaceSession
{
SessionTokenHash = tokenHash,
Username = account.Username,
Role = account.Role,
CreatedAt = now.ToString("O"),
ExpiresAt = expiresAt.ToString("O"),
RevokedAt = null
});
// Set HTTP-only auth cookie (Secure=true since production is always HTTPS via Cloudflare)
Response.Cookies.Append(
"quant_auth_token",
rawToken,
new Microsoft.AspNetCore.Http.CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax,
Expires = expiresAt,
Path = "/"
}
);
if (rememberUsername)
{
Response.Cookies.Append(
"quant_admin_username",
username,
new Microsoft.AspNetCore.Http.CookieOptions
{
Expires = DateTimeOffset.UtcNow.AddDays(30),
HttpOnly = false,
SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict
}
);
}
else
{
Response.Cookies.Delete("quant_admin_username");
}
_logger.LogInformation("[Login] User '{Username}' authenticated successfully", account.Username);
return Redirect("/");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -99,7 +141,6 @@ namespace QuantEngine.Web.Pages.Account
public IActionResult OnGetLogout() public IActionResult OnGetLogout()
{ {
// Revoke cookies securely
Response.Cookies.Delete("quant_auth_token"); Response.Cookies.Delete("quant_auth_token");
return Redirect("/Account/Login"); return Redirect("/Account/Login");
} }