feat: implement server-side cookie-based authentication
- Add HTTP-only cookie setting in /api/auth/login endpoint - Support both Bearer token and cookie auth in /api/auth/me - Clear cookie on /api/auth/logout - Handle admin:admin dev fallback with cookie support - Update login.html to use 1 second redirect (cookie-based auth faster) Cookie configuration: - Name: quant_auth_token - HttpOnly: true (prevents JavaScript access) - Secure: based on HTTPS status - SameSite: Lax (for localhost compatibility) - Expires: 7 days Status: Cookie auth framework complete, testing in progress Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 211 KiB |
@@ -0,0 +1,53 @@
|
|||||||
|
import { chromium } from "@playwright/test";
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const b = await chromium.launch();
|
||||||
|
const p = await b.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await p.goto("http://localhost:5265/login");
|
||||||
|
console.log("✓ 1. Login page loaded");
|
||||||
|
|
||||||
|
// Submit login
|
||||||
|
await p.fill("input[name=\"username\"]", "admin");
|
||||||
|
await p.fill("input[name=\"password\"]", "admin");
|
||||||
|
await p.click("button[type=\"submit\"]");
|
||||||
|
console.log("✓ 2. Login submitted");
|
||||||
|
|
||||||
|
// Wait for navigation
|
||||||
|
try {
|
||||||
|
await p.waitForNavigation({ waitUntil: "load", timeout: 6000 });
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
// Check cookies
|
||||||
|
const cookies = await p.context().cookies();
|
||||||
|
const hasAuthCookie = cookies.some(c => c.name === "quant_auth_token");
|
||||||
|
console.log(`✓ 3. Auth cookie: ${hasAuthCookie ? "YES" : "NO"}`);
|
||||||
|
|
||||||
|
const url = p.url();
|
||||||
|
const content = await p.content();
|
||||||
|
|
||||||
|
console.log(`\n📍 Final URL: ${url}`);
|
||||||
|
|
||||||
|
if (url.includes("/dashboard")) {
|
||||||
|
if (content.includes("관리자 대시보드")) {
|
||||||
|
console.log("✓✓✓ SUCCESS: Dashboard fully loaded!");
|
||||||
|
} else if (content.includes("Not Found")) {
|
||||||
|
console.log("✗ Dashboard URL but Not Found error");
|
||||||
|
} else {
|
||||||
|
console.log("✓ Dashboard page (content varies)");
|
||||||
|
}
|
||||||
|
} else if (url.includes("/login")) {
|
||||||
|
console.log("⚠ Back at login (auth failed)");
|
||||||
|
} else {
|
||||||
|
console.log("? Other page");
|
||||||
|
}
|
||||||
|
|
||||||
|
await p.screenshot({ path: "./final-login-test.png" });
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error:", e.message.substring(0, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
await b.close();
|
||||||
|
})();
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 199 KiB |
@@ -204,7 +204,7 @@ app.MapGet("/login", (HttpContext ctx) =>
|
|||||||
app.MapCollectionEndpoints();
|
app.MapCollectionEndpoints();
|
||||||
|
|
||||||
// Login API (API-First for Blazor WASM client authentication)
|
// Login API (API-First for Blazor WASM client authentication)
|
||||||
app.MapPost("/api/auth/login", async (JsonElement payload, IWorkspaceRepository workspaceRepo) =>
|
app.MapPost("/api/auth/login", async (JsonElement payload, HttpContext httpContext, IWorkspaceRepository workspaceRepo, IWebHostEnvironment env) =>
|
||||||
{
|
{
|
||||||
static string? ReadString(JsonElement root, params string[] names)
|
static string? ReadString(JsonElement root, params string[] names)
|
||||||
{
|
{
|
||||||
@@ -240,6 +240,21 @@ app.MapPost("/api/auth/login", async (JsonElement payload, IWorkspaceRepository
|
|||||||
{
|
{
|
||||||
var devToken = Guid.NewGuid().ToString("N");
|
var devToken = Guid.NewGuid().ToString("N");
|
||||||
var devExpiresAt = DateTimeOffset.UtcNow.AddDays(7);
|
var devExpiresAt = DateTimeOffset.UtcNow.AddDays(7);
|
||||||
|
|
||||||
|
// Set HTTP-only cookie for dev fallback too
|
||||||
|
httpContext.Response.Cookies.Append(
|
||||||
|
"quant_auth_token",
|
||||||
|
devToken,
|
||||||
|
new Microsoft.AspNetCore.Http.CookieOptions
|
||||||
|
{
|
||||||
|
HttpOnly = true,
|
||||||
|
Secure = httpContext.Request.IsHttps,
|
||||||
|
SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax,
|
||||||
|
Expires = devExpiresAt,
|
||||||
|
Path = "/"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return Results.Ok(new
|
return Results.Ok(new
|
||||||
{
|
{
|
||||||
success = true,
|
success = true,
|
||||||
@@ -278,6 +293,22 @@ app.MapPost("/api/auth/login", async (JsonElement payload, IWorkspaceRepository
|
|||||||
RevokedAt = null
|
RevokedAt = null
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set HTTP-only cookie for server-side authentication
|
||||||
|
// Note: Secure=true only in production (HTTPS); localhost uses HTTP
|
||||||
|
httpContext.Response.Cookies.Append(
|
||||||
|
"quant_auth_token",
|
||||||
|
rawToken,
|
||||||
|
new Microsoft.AspNetCore.Http.CookieOptions
|
||||||
|
{
|
||||||
|
HttpOnly = true,
|
||||||
|
Secure = httpContext.Request.IsHttps, // Only secure on HTTPS
|
||||||
|
SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax, // Lax for localhost
|
||||||
|
Expires = expiresAt,
|
||||||
|
Path = "/"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also return token for localStorage backup (for SPA navigation)
|
||||||
return Results.Ok(new
|
return Results.Ok(new
|
||||||
{
|
{
|
||||||
success = true,
|
success = true,
|
||||||
@@ -290,13 +321,19 @@ app.MapPost("/api/auth/login", async (JsonElement payload, IWorkspaceRepository
|
|||||||
|
|
||||||
app.MapGet("/api/auth/me", async (HttpContext context, IWorkspaceRepository workspaceRepo) =>
|
app.MapGet("/api/auth/me", async (HttpContext context, IWorkspaceRepository workspaceRepo) =>
|
||||||
{
|
{
|
||||||
|
// Try to get token from Bearer header first, then fall back to cookie
|
||||||
|
var token = "";
|
||||||
|
|
||||||
var authHeader = context.Request.Headers.Authorization.ToString();
|
var authHeader = context.Request.Headers.Authorization.ToString();
|
||||||
if (string.IsNullOrWhiteSpace(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
if (!string.IsNullOrWhiteSpace(authHeader) && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return Results.Unauthorized();
|
token = authHeader["Bearer ".Length..].Trim();
|
||||||
|
}
|
||||||
|
else if (context.Request.Cookies.TryGetValue("quant_auth_token", out var cookieToken))
|
||||||
|
{
|
||||||
|
token = cookieToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
var token = authHeader["Bearer ".Length..].Trim();
|
|
||||||
if (string.IsNullOrWhiteSpace(token))
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
{
|
{
|
||||||
return Results.Unauthorized();
|
return Results.Unauthorized();
|
||||||
@@ -328,6 +365,10 @@ app.MapPost("/api/auth/logout", async (HttpContext context, IWorkspaceRepository
|
|||||||
|
|
||||||
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||||
await workspaceRepo.RevokeSessionAsync(tokenHash, DateTimeOffset.UtcNow.ToString("O"));
|
await workspaceRepo.RevokeSessionAsync(tokenHash, DateTimeOffset.UtcNow.ToString("O"));
|
||||||
|
|
||||||
|
// Clear authentication cookie
|
||||||
|
context.Response.Cookies.Delete("quant_auth_token");
|
||||||
|
|
||||||
return Results.Ok(new { success = true });
|
return Results.Ok(new { success = true });
|
||||||
}).DisableAntiforgery();
|
}).DisableAntiforgery();
|
||||||
|
|
||||||
|
|||||||
@@ -320,13 +320,13 @@
|
|||||||
|
|
||||||
// 로그인 성공 메시지 표시
|
// 로그인 성공 메시지 표시
|
||||||
alertDiv.className = 'alert alert-success show';
|
alertDiv.className = 'alert alert-success show';
|
||||||
alertDiv.textContent = '로그인 성공! 대시보드로 이동합니다...';
|
alertDiv.textContent = '로그인 성공! 대시보드로 이동합니다.';
|
||||||
|
|
||||||
// Blazor 앱이 localStorage에서 토큰을 읽고 초기화할 시간을 충분히 준다
|
// 쿠키가 설정되었으므로 짧은 대기 시간만 필요
|
||||||
// 4초 대기 후 대시보드로 이동
|
// (Blazor가 쿠키를 자동으로 인식함)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '/dashboard';
|
window.location.href = '/dashboard';
|
||||||
}, 4000);
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
alertDiv.className = 'alert alert-error show';
|
alertDiv.className = 'alert alert-error show';
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { chromium } from "@playwright/test";
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const b = await chromium.launch();
|
||||||
|
const p = await b.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await p.goto("http://localhost:5265/login");
|
||||||
|
console.log("✓ Login loaded");
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
await p.fill("input[name=\"username\"]", "admin");
|
||||||
|
await p.fill("input[name=\"password\"]", "admin");
|
||||||
|
await p.click("button[type=\"submit\"]");
|
||||||
|
|
||||||
|
// Wait for navigation
|
||||||
|
try { await p.waitForNavigation({ timeout: 5000 }); } catch { }
|
||||||
|
|
||||||
|
// Check cookie and URL
|
||||||
|
const cookies = await p.context().cookies();
|
||||||
|
const hasCookie = cookies.some(c => c.name === "quant_auth_token");
|
||||||
|
const url = p.url();
|
||||||
|
|
||||||
|
console.log(`Auth cookie: ${hasCookie ? "YES" : "NO"}`);
|
||||||
|
console.log(`URL: ${url}`);
|
||||||
|
|
||||||
|
if (url.includes("/dashboard") && !url.includes("/not-found")) {
|
||||||
|
console.log("✓✓✓ SUCCESS!");
|
||||||
|
}
|
||||||
|
|
||||||
|
await p.screenshot({ path: "./auth-test.png" });
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await b.close();
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user