This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
@page "/admin/login"
|
||||
@model TaxBaik.Web.Pages.Admin.LoginModel
|
||||
@inject VersionInfo VersionInfo
|
||||
@{
|
||||
Layout = null;
|
||||
Layout = "~/Pages/Shared/_AdminLayout.cshtml";
|
||||
ViewData["Title"] = "관리자 로그인";
|
||||
ViewData["Description"] = "관리자 로그인 페이지입니다.";
|
||||
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/admin/login";
|
||||
@@ -27,6 +28,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-4 small text-muted">
|
||||
<span>현재 버전</span>
|
||||
<strong class="text-body">v@(VersionInfo.Version) · @VersionInfo.Built</strong>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">@Model.ErrorMessage</div>
|
||||
@@ -49,8 +55,8 @@
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" id="RememberMe" name="RememberMe" type="checkbox" value="true" checked="@(Model.RememberMe)" />
|
||||
<label class="form-check-label" for="RememberMe">로그인 상태 유지</label>
|
||||
<input class="form-check-input" id="RememberUsername" name="RememberUsername" type="checkbox" value="true" checked="@(Model.RememberUsername)" />
|
||||
<label class="form-check-label" for="RememberUsername">아이디 기억하기</label>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-dark w-100" id="admin-login-submit" type="submit">
|
||||
@@ -59,7 +65,7 @@
|
||||
</form>
|
||||
|
||||
<p class="admin-login-footnote">
|
||||
이 페이지는 서버 렌더링 기반이며, 로그인 성공 후 관리자 웹앱으로 이동합니다.
|
||||
관리자 로그인에 성공하면 대시보드로 이동합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using TaxBaik.Web.Services;
|
||||
|
||||
namespace TaxBaik.Web.Pages.Admin;
|
||||
|
||||
[EnableRateLimiting("admin-login")]
|
||||
public class LoginModel(AuthService authService) : PageModel
|
||||
{
|
||||
private const string RememberedUsernameCookieName = "TaxBaik.Admin.RememberedUsername";
|
||||
|
||||
[BindProperty]
|
||||
[Display(Name = "사용자명")]
|
||||
[Required(ErrorMessage = "사용자명을 입력하세요.")]
|
||||
@@ -21,8 +26,8 @@ public class LoginModel(AuthService authService) : PageModel
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
[BindProperty]
|
||||
[Display(Name = "로그인 상태 유지")]
|
||||
public bool RememberMe { get; set; } = true;
|
||||
[Display(Name = "아이디 기억하기")]
|
||||
public bool RememberUsername { get; set; } = true;
|
||||
|
||||
[BindProperty(SupportsGet = true)]
|
||||
public string? ReturnUrl { get; set; }
|
||||
@@ -32,6 +37,13 @@ public class LoginModel(AuthService authService) : PageModel
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
if (Request.Cookies.TryGetValue(RememberedUsernameCookieName, out var rememberedUsername) &&
|
||||
!string.IsNullOrWhiteSpace(rememberedUsername))
|
||||
{
|
||||
Username = rememberedUsername;
|
||||
RememberUsername = true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ReturnUrl))
|
||||
{
|
||||
RedirectUrl = NormalizeRedirectUrl(ReturnUrl);
|
||||
@@ -63,9 +75,27 @@ public class LoginModel(AuthService authService) : PageModel
|
||||
|
||||
var identity = new ClaimsIdentity(claims, AdminAuthDefaults.Scheme);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
if (RememberUsername)
|
||||
{
|
||||
Response.Cookies.Append(
|
||||
RememberedUsernameCookieName,
|
||||
user.Username,
|
||||
new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = Request.IsHttps,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Expires = DateTimeOffset.UtcNow.AddDays(30)
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
Response.Cookies.Delete(RememberedUsernameCookieName);
|
||||
}
|
||||
|
||||
var properties = new AuthenticationProperties
|
||||
{
|
||||
IsPersistent = RememberMe,
|
||||
IsPersistent = false,
|
||||
RedirectUri = RedirectUrl
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
@page
|
||||
@model TaxBaik.Web.Pages.Admin.LogoutModel
|
||||
@{
|
||||
Layout = null;
|
||||
Layout = "~/Pages/Shared/_AdminLayout.cshtml";
|
||||
}
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="refresh" content="0;url=/admin/login" />
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
<section class="admin-login-shell">
|
||||
<div class="card shadow-sm border-0 admin-login-card">
|
||||
<div class="card-body p-4 p-md-5 text-center">
|
||||
<h1 class="h4 mb-2">로그아웃 처리 중</h1>
|
||||
<p class="text-muted mb-0">잠시 후 로그인 화면으로 이동합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<meta http-equiv="refresh" content="0;url=/admin/login" />
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
@{
|
||||
var path = (Context.Request.Path.Value ?? string.Empty).ToLowerInvariant();
|
||||
Func<string, bool> isActive = target => path.StartsWith(target.ToLowerInvariant(), StringComparison.OrdinalIgnoreCase);
|
||||
Func<string, string> activeClass = target => isActive(target) ? "active" : string.Empty;
|
||||
}
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
@@ -11,24 +16,95 @@
|
||||
</head>
|
||||
<body class="admin-app">
|
||||
<div class="page">
|
||||
<header class="navbar navbar-expand-md d-print-none">
|
||||
<div class="container-xl">
|
||||
<div class="navbar-brand navbar-brand-autodark">TaxBaik Admin</div>
|
||||
<div class="navbar-nav flex-row order-md-last">
|
||||
<div class="nav-item">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="/admin/logout">로그아웃</a>
|
||||
</div>
|
||||
<header class="navbar navbar-expand-md d-print-none admin-topbar">
|
||||
<div class="container-fluid">
|
||||
<div class="navbar-brand navbar-brand-autodark">
|
||||
<a href="/admin/dashboard" class="text-decoration-none text-reset fw-semibold">TaxBaik Admin</a>
|
||||
</div>
|
||||
<div class="navbar-nav flex-row order-md-last gap-2 align-items-center">
|
||||
<span class="text-muted small d-none d-md-inline">@User.Identity?.Name</span>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="/admin/logout">로그아웃</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="page-wrapper">
|
||||
<div class="container-xl">
|
||||
<div class="container-fluid">
|
||||
<div class="row g-3">
|
||||
<aside class="col-12 col-lg-2">
|
||||
<partial name="_AdminSidebar" />
|
||||
<aside class="col-12 col-lg-3 col-xxl-2">
|
||||
<div class="card shadow-sm border-0 admin-sidebar-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">관리 메뉴</h3>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="accordion accordion-flush" id="adminSidebarAccordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button @(isActive("/admin/dashboard") || isActive("/admin") && !isActive("/admin/blog") && !isActive("/admin/clients") ? string.Empty : "collapsed")" type="button" data-bs-toggle="collapse" data-bs-target="#adminMenuCore" aria-expanded="true">
|
||||
기본
|
||||
</button>
|
||||
</h2>
|
||||
<div id="adminMenuCore" class="accordion-collapse collapse show" data-bs-parent="#adminSidebarAccordion">
|
||||
<div class="list-group list-group-flush list-group-transparent">
|
||||
<a class="list-group-item list-group-item-action @activeClass("/admin/dashboard")" href="/admin/dashboard">대시보드</a>
|
||||
<a class="list-group-item list-group-item-action @activeClass("/admin/settings")" href="/admin/settings">설정</a>
|
||||
<a class="list-group-item list-group-item-action @activeClass("/admin/clientlogs")" href="/admin/clientlogs">클라이언트 로그</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#adminMenuCustomer" aria-expanded="false">
|
||||
고객/상담
|
||||
</button>
|
||||
</h2>
|
||||
<div id="adminMenuCustomer" class="accordion-collapse collapse" data-bs-parent="#adminSidebarAccordion">
|
||||
<div class="list-group list-group-flush list-group-transparent">
|
||||
<a class="list-group-item list-group-item-action @activeClass("/admin/customers")" href="/admin/customers">고객</a>
|
||||
<a class="list-group-item list-group-item-action @activeClass("/admin/clients")" href="/admin/clients">고객사</a>
|
||||
<a class="list-group-item list-group-item-action @activeClass("/admin/inquiries")" href="/admin/inquiries">문의</a>
|
||||
<a class="list-group-item list-group-item-action @activeClass("/admin/consultingactivities")" href="/admin/consultingactivities">상담 활동</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#adminMenuContent" aria-expanded="false">
|
||||
콘텐츠
|
||||
</button>
|
||||
</h2>
|
||||
<div id="adminMenuContent" class="accordion-collapse collapse" data-bs-parent="#adminSidebarAccordion">
|
||||
<div class="list-group list-group-flush list-group-transparent">
|
||||
<a class="list-group-item list-group-item-action @activeClass("/admin/blog")" href="/admin/blog">블로그</a>
|
||||
<a class="list-group-item list-group-item-action @activeClass("/admin/announcements")" href="/admin/announcements">공지사항</a>
|
||||
<a class="list-group-item list-group-item-action @activeClass("/admin/faqs")" href="/admin/faqs">FAQ</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#adminMenuFinance" aria-expanded="false">
|
||||
세무 관리
|
||||
</button>
|
||||
</h2>
|
||||
<div id="adminMenuFinance" class="accordion-collapse collapse" data-bs-parent="#adminSidebarAccordion">
|
||||
<div class="list-group list-group-flush list-group-transparent">
|
||||
<a class="list-group-item list-group-item-action @activeClass("/admin/taxprofiles")" href="/admin/taxprofiles">세무 프로필</a>
|
||||
<a class="list-group-item list-group-item-action @activeClass("/admin/taxfilings")" href="/admin/taxfilings">신고 관리</a>
|
||||
<a class="list-group-item list-group-item-action @activeClass("/admin/taxfilingschedules")" href="/admin/taxfilingschedules">신고 일정</a>
|
||||
<a class="list-group-item list-group-item-action @activeClass("/admin/revenuetrackings")" href="/admin/revenuetrackings">매출 추적</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="col-12 col-lg-10">
|
||||
@RenderBody()
|
||||
<main class="col-12 col-lg-9 col-xxl-10">
|
||||
<div class="card shadow-sm border-0 admin-content-card">
|
||||
<div class="card-body p-3 p-md-4 p-xl-5">
|
||||
@RenderBody()
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
@inject VersionInfo VersionInfo
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@(ViewData["Title"] ?? "TaxBaik Admin")</title>
|
||||
<meta name="description" content="@(ViewData["Description"] ?? "TaxBaik 관리자 화면")" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="~/css/admin.css" asp-append-version="true" />
|
||||
</head>
|
||||
<body class="admin-login-page">
|
||||
@RenderBody()
|
||||
|
||||
<footer class="admin-login-footer">
|
||||
<div class="container py-3">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 small text-muted">
|
||||
<span>TaxBaik Admin</span>
|
||||
<span>v@(VersionInfo.Version) · @VersionInfo.Built</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
</body>
|
||||
</html>
|
||||
@@ -70,6 +70,19 @@ builder.Services.AddRateLimiter(options =>
|
||||
AutoReplenishment = true
|
||||
});
|
||||
});
|
||||
options.AddPolicy("admin-login", httpContext =>
|
||||
{
|
||||
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
return RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey: $"admin-login:{ip}",
|
||||
factory: _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 5,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Razor Pages
|
||||
@@ -319,6 +332,20 @@ app.UseRateLimiter();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseAntiforgery();
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||
context.Response.Headers["X-Frame-Options"] = "DENY";
|
||||
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
|
||||
context.Response.Headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()";
|
||||
context.Response.Headers["X-Permitted-Cross-Domain-Policies"] = "none";
|
||||
if (!context.Response.Headers.ContainsKey("Content-Security-Policy"))
|
||||
{
|
||||
context.Response.Headers["Content-Security-Policy"] = "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://www.googletagmanager.com; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self';";
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
// Razor Pages + 정적 자산 매핑
|
||||
app.MapHealthChecks("/healthz");
|
||||
|
||||
Reference in New Issue
Block a user