From 054596446872c3fa031106b500d32c6994ef86d1 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Thu, 9 Jul 2026 11:07:09 +0900 Subject: [PATCH] Refactor admin login to cookie auth --- docs/ADMIN_RAZORPAGES_ROADMAP.md | 104 ++++++++++++++++++ docs/INDEX.md | 7 +- src/TaxBaik.Web/Pages/Admin/Login.cshtml | 30 ----- src/TaxBaik.Web/Pages/Admin/Login.cshtml.cs | 40 +++---- src/TaxBaik.Web/Program.cs | 13 ++- src/TaxBaik.Web/Services/AdminAuthDefaults.cs | 8 ++ src/TaxBaik.Web/Services/AuthService.cs | 29 +++++ 7 files changed, 177 insertions(+), 54 deletions(-) create mode 100644 docs/ADMIN_RAZORPAGES_ROADMAP.md create mode 100644 src/TaxBaik.Web/Services/AdminAuthDefaults.cs diff --git a/docs/ADMIN_RAZORPAGES_ROADMAP.md b/docs/ADMIN_RAZORPAGES_ROADMAP.md new file mode 100644 index 0000000..ef62a0a --- /dev/null +++ b/docs/ADMIN_RAZORPAGES_ROADMAP.md @@ -0,0 +1,104 @@ +# 고객관리 어드민 Razor Pages 전환 로드맵 + +이 문서는 고객관리 어드민을 `ASP.NET Core 10 + Razor Pages + Dapper + PostgreSQL 18.4 + Flyway + Cookie Authentication + FluentValidation + Tabler UI + Minimal TypeScript` 기준으로 하네스하기 위한 실행 문서다. + +## 목표 + +- 서버 렌더링 중심의 단순한 어드민 구조로 정리한다. +- 관리자 로그인은 Cookie Authentication 으로 통일한다. +- DB 변경은 Flyway migration 으로 관리한다. +- PageModel 은 흐름만 담당하고, SQL 은 Repository 또는 Query Class 로 보낸다. +- 화면은 Tabler UI 기반 공통 Partial 과 최소한의 ViewComponent 로 정리한다. + +## 범위 + +- 포함 +- 관리자 로그인 +- 관리자 공통 레이아웃 +- 고객 CRUD +- 관리자 유저 관리 +- 대시보드 +- 검증/테스트 하네스 + +- 제외 +- SPA 라우팅 +- React/Vue/Angular +- JWT 기반 브라우저 로그인 +- API-first 어드민 화면 +- 과도한 Clean Architecture + +## 실행 원칙 + +- 먼저 문서와 폴더 기준을 고정한다. +- 다음으로 인증과 진입점부터 서버 렌더링으로 맞춘다. +- 이후 CRUD 화면을 페이지 단위로 옮긴다. +- 마지막에 로컬 테스트, 커밋, 푸시를 수행한다. + +## 로드맵 + +### Phase 0. 기준 고정 + +- 아키텍처 원칙 문서화 +- WBS 상세화 +- 어드민 라우트와 공통 컴포넌트 규칙 확정 +- 기존 Blazor / API / JWT 경로는 레거시로 분리 + +### Phase 1. 인증 전환 + +- `/admin/login` 을 Razor Pages form 기반으로 유지 +- 로그인 성공 시 Cookie Authentication 발급 +- `/admin` 하위 페이지에 인증 정책 적용 +- Logout 은 쿠키 삭제 기반으로 정리 + +### Phase 2. 기본 CRUD 전환 + +- 고객 목록/상세/등록/수정/삭제 페이지 작성 +- Request / Dto 분리 +- FluentValidation 적용 +- Dapper SQL 을 Repository 로 이동 + +### Phase 3. 공통 UI 정리 + +- `_Layout`, `_Sidebar`, `_Topbar` 정리 +- `_ValidationSummary`, `_Pagination`, `_SearchBox`, `_ConfirmModal`, `_StatusBadge`, `_EmptyState` 정리 +- 반복 헤더는 ViewComponent 로 정리 + +### Phase 4. DB 정합성 + +- Flyway migration 누락 점검 +- 인덱스, 제약, soft delete 정책 정리 +- seed data 와 운영 데이터 경계 정리 + +### Phase 5. 테스트 및 검증 + +- 로컬 빌드 통과 +- 로컬 테스트 통과 +- Playwright 시나리오 통과 +- 변경분 커밋 및 푸시 + +## WBS + +| ID | 작업 | 완료 기준 | +| --- | --- | --- | +| RP-01 | 어드민 아키텍처 문서화 | 본 문서와 하네스 문서가 `docs/INDEX.md`에 연결됨 | +| RP-02 | 로그인 쿠키 전환 | `/admin/login` 성공 시 Cookie Auth 가 발급되고 localStorage 토큰 저장이 제거됨 | +| RP-03 | 관리자 인증 정책 적용 | `/admin` 하위 페이지가 인증 없이 열리지 않음 | +| RP-04 | 공통 레이아웃 정리 | Tabler 기반 공통 레이아웃/partial 기준이 문서화됨 | +| RP-05 | 고객 CRUD 설계 | Request/Dto/Validator/Service/Repository 분리가 완료됨 | +| RP-06 | Flyway 보강 | 필요한 테이블/인덱스/제약이 migration 으로 관리됨 | +| RP-07 | 로컬 테스트 | `dotnet build`, 단위 테스트, Playwright 이 통과함 | +| RP-08 | 커밋/푸시 | 변경이 의도된 커밋으로 정리되고 원격에 반영됨 | + +## 완료 정의 + +기능 하나가 완료되려면 아래가 충족되어야 한다. + +- Razor Page 와 PageModel 이 존재한다. +- Request / Dto / Validator 가 분리되어 있다. +- Service 가 흐름을 담당한다. +- Repository 또는 Query Class 가 Dapper SQL 을 담당한다. +- 필요한 경우 Flyway migration 이 추가되어 있다. +- Tabler UI 기준으로 화면이 표시된다. +- 인증과 권한이 일치한다. +- 로컬 빌드와 테스트가 통과한다. + diff --git a/docs/INDEX.md b/docs/INDEX.md index eb3817b..d02158f 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -7,6 +7,7 @@ | 문서 | 용도 | 변경 조건 | | --- | --- | --- | | [ENGINEERING_HARNESS.md](./ENGINEERING_HARNESS.md) | 아키텍처, 코드 품질, 배포, 데이터 정합성 하네스 | 방향성 변경 또는 반복 위반 발견 | +| [ADMIN_RAZORPAGES_ROADMAP.md](./ADMIN_RAZORPAGES_ROADMAP.md) | 고객관리 어드민 Razor Pages 전환 로드맵과 WBS | 어드민 인증/레이아웃/CRUD 전환 작업 변경 | | [STACK_TEMPLATE_WBS.md](./STACK_TEMPLATE_WBS.md) | MudBlazor + ASP.NET Core 10 + Dapper + PostgreSQL 18.4 + API-first + SSR/Interactive render mode 기준 템플릿, 로드맵, WBS | 새 스택 채택 또는 렌더/인증 기준 변경 | | [DOUZONE_UX_GUIDE.md](./DOUZONE_UX_GUIDE.md) | 더존식 어드민 UX 원칙, 템플릿, 컴포넌트, 서빙 규칙 | 화면 패턴 변경 또는 신규 템플릿 추가 | | [OPS_LOG_DIAGNOSTICS_ROADMAP.md](./OPS_LOG_DIAGNOSTICS_ROADMAP.md) | 관리자 대시보드/클라이언트 로그 진단 로드맵 | 로그 진단 흐름 또는 관측성 작업 변경 | @@ -20,9 +21,9 @@ | 영역 | 라우트/파일 | 기준 | | --- | --- | --- | | Public Home/Blog/Contact | `/`, `/blog`, `/contact` | 서버 사이드 렌더링, SEO 우선, WASM 의존 금지 | -| Admin Blog | `/admin/blog`, `/admin/blog/create`, `/admin/blog/{id}/edit` | 클라이언트 사이드 Blazor WebAssembly, API-first 클라이언트 경유, JS 최소화 | -| Admin Inquiry | `/admin/inquiries`, `/admin/inquiries/create`, `/admin/inquiries/{id}/edit` | 클라이언트 사이드 Blazor WebAssembly, 공개 접수/관리자 등록/상태 변경 분리 | -| Public API | `/api/*` | JWT 인증, ProblemDetails 오류, DTO 입출력 | +| Admin Blog | `/admin/blog`, `/admin/blog/create`, `/admin/blog/{id}/edit` | 현행 Blazor/Web API 경로, 전환 대상 | +| Admin Inquiry | `/admin/inquiries`, `/admin/inquiries/create`, `/admin/inquiries/{id}/edit` | 현행 Blazor/Web API 경로, 전환 대상 | +| Public API | `/api/*` | 현행 JWT 경로, 브라우저 어드민과 분리 | | CI/CD | `.gitea/workflows/deploy.yml`, `.gitea/workflows/browser-e2e.yml` | 수동 배포 금지, 배포본 E2E 통과 후 완료 | | Smoke | `scripts/taxbaik-smoke.sh` | 루트는 공용 홈, `/admin/login` 은 관리자 화면이어야 함 | diff --git a/src/TaxBaik.Web/Pages/Admin/Login.cshtml b/src/TaxBaik.Web/Pages/Admin/Login.cshtml index 2d4c231..f0ca1bb 100644 --- a/src/TaxBaik.Web/Pages/Admin/Login.cshtml +++ b/src/TaxBaik.Web/Pages/Admin/Login.cshtml @@ -64,33 +64,3 @@ -@if (Model.LoginSucceeded && Model.TokenPair is not null) -{ - -} diff --git a/src/TaxBaik.Web/Pages/Admin/Login.cshtml.cs b/src/TaxBaik.Web/Pages/Admin/Login.cshtml.cs index cc1f1f0..246be05 100644 --- a/src/TaxBaik.Web/Pages/Admin/Login.cshtml.cs +++ b/src/TaxBaik.Web/Pages/Admin/Login.cshtml.cs @@ -1,7 +1,8 @@ using System.ComponentModel.DataAnnotations; +using System.Security.Claims; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -using TaxBaik.Web.Endpoints.Auth; +using Microsoft.AspNetCore.Authentication; using TaxBaik.Web.Services; namespace TaxBaik.Web.Pages.Admin; @@ -27,11 +28,6 @@ public class LoginModel(AuthService authService) : PageModel public string? ReturnUrl { get; set; } public string? ErrorMessage { get; set; } - - public bool LoginSucceeded { get; set; } - - public TokenPairResponse? TokenPair { get; set; } - public string RedirectUrl { get; set; } = "/admin/dashboard"; public void OnGet() @@ -49,28 +45,32 @@ public class LoginModel(AuthService authService) : PageModel return Page(); } - var authResult = await authService.AuthenticateAndGenerateTokenPairAsync(Username, Password); - if (!authResult.IsSuccess || authResult.TokenPair is null) + var user = await authService.AuthenticateAsync(Username, Password); + if (user is null) { - ErrorMessage = authResult.FailureReason switch - { - AuthFailureReason.DatabaseUnavailable => "로그인 서비스를 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도하세요.", - _ => "아이디 또는 비밀번호가 올바르지 않습니다." - }; + ErrorMessage = "아이디 또는 비밀번호가 올바르지 않습니다."; return Page(); } - LoginSucceeded = true; RedirectUrl = NormalizeRedirectUrl(ReturnUrl); - TokenPair = new TokenPairResponse + + var claims = new List { - AccessToken = authResult.TokenPair.AccessToken, - RefreshToken = authResult.TokenPair.RefreshToken, - ExpiresIn = authResult.TokenPair.ExpiresIn, - Token = authResult.TokenPair.AccessToken + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(ClaimTypes.Name, user.Username), + new(ClaimTypes.Role, "Admin") }; - return Page(); + var identity = new ClaimsIdentity(claims, AdminAuthDefaults.Scheme); + var principal = new ClaimsPrincipal(identity); + var properties = new AuthenticationProperties + { + IsPersistent = RememberMe, + RedirectUri = RedirectUrl + }; + + await HttpContext.SignInAsync(AdminAuthDefaults.Scheme, principal, properties); + return LocalRedirect(RedirectUrl); } private static string NormalizeRedirectUrl(string? returnUrl) diff --git a/src/TaxBaik.Web/Program.cs b/src/TaxBaik.Web/Program.cs index 4051a06..26d7972 100644 --- a/src/TaxBaik.Web/Program.cs +++ b/src/TaxBaik.Web/Program.cs @@ -115,7 +115,7 @@ builder.Services.AddSession(options => builder.Services.AddDistributedMemoryCache(); // TempData는 기본적으로 쿠키 저장소 사용 (위 세션 설정 상속) -// JWT 인증 +// 인증: 관리자 쿠키 + 포털 쿠키 + 기존 JWT(과도기 API) var connectionString = builder.Configuration.GetConnectionString("Default") ?? throw new InvalidOperationException("Missing connection string"); var jwtKey = builder.Configuration["Jwt:SecretKey"] ?? throw new InvalidOperationException("Missing JWT SecretKey"); @@ -153,6 +153,17 @@ var authenticationBuilder = builder.Services.AddAuthentication(opts => opts.SlidingExpiration = true; opts.ExpireTimeSpan = TimeSpan.FromDays(7); }) +.AddCookie(AdminAuthDefaults.Scheme, opts => +{ + opts.Cookie.Name = AdminAuthDefaults.CookieName; + opts.Cookie.HttpOnly = true; + opts.Cookie.SameSite = SameSiteMode.Lax; + opts.Cookie.SecurePolicy = isProduction ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest; + opts.LoginPath = "/admin/login"; + opts.AccessDeniedPath = "/admin/login"; + opts.SlidingExpiration = true; + opts.ExpireTimeSpan = TimeSpan.FromHours(12); +}) .AddCookie(PortalOAuthDefaults.ExternalScheme, opts => { opts.Cookie.Name = "TaxBaik.Portal.External"; diff --git a/src/TaxBaik.Web/Services/AdminAuthDefaults.cs b/src/TaxBaik.Web/Services/AdminAuthDefaults.cs new file mode 100644 index 0000000..5811032 --- /dev/null +++ b/src/TaxBaik.Web/Services/AdminAuthDefaults.cs @@ -0,0 +1,8 @@ +namespace TaxBaik.Web.Services; + +public static class AdminAuthDefaults +{ + public const string Scheme = "AdminCookie"; + public const string CookieName = "TaxBaik.Admin.Auth"; +} + diff --git a/src/TaxBaik.Web/Services/AuthService.cs b/src/TaxBaik.Web/Services/AuthService.cs index cf38bc1..92e97fb 100644 --- a/src/TaxBaik.Web/Services/AuthService.cs +++ b/src/TaxBaik.Web/Services/AuthService.cs @@ -88,6 +88,35 @@ public class AuthService return AuthResult.Success(GenerateTokenPair(user)); } + public async Task AuthenticateAsync(string username, string password) + { + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + return null; + + try + { + var user = await _adminUserRepository.GetByUsernameAsync(username); + if (user == null || string.IsNullOrWhiteSpace(user.PasswordHash)) + return null; + + if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash)) + return null; + + await _adminUserRepository.UpdateLastLoginAtAsync(user.Id); + return user; + } + catch (Exception ex) when (_environment.IsDevelopment()) + { + _logger.LogError(ex, "개발 환경 관리자 인증 실패: {Username}", username); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "관리자 인증 중 오류: {Username}", username); + return null; + } + } + public async Task RefreshAccessTokenAsync(string refreshToken) { try