feat(portal): 고객 포털 인증과 소셜 로그인 기반 추가

This commit is contained in:
2026-06-28 18:39:29 +09:00
parent 033883aac5
commit e2472b7ea1
20 changed files with 644 additions and 1 deletions
+6
View File
@@ -3,3 +3,9 @@ ASPNETCORE_URLS=http://0.0.0.0:5001
ConnectionStrings__Default=Host=localhost;Database=taxbaikdb;Username=taxbaik;Password=change-me
Jwt__SecretKey=dev-secret-key-change-in-production-min-32-chars!
Admin__PasswordResetToken=change-this-reset-token
Authentication__Google__ClientId=
Authentication__Google__ClientSecret=
Authentication__Naver__ClientId=
Authentication__Naver__ClientSecret=
Authentication__Kakao__ClientId=
Authentication__Kakao__ClientSecret=
@@ -0,0 +1,59 @@
namespace TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Interfaces;
public class PortalUserService(IPortalUserRepository repository)
{
public async Task<PortalUser?> GetByEmailAsync(string email, CancellationToken ct = default) =>
await repository.GetByEmailAsync(email.Trim(), ct);
public async Task<PortalUser?> GetByProviderAsync(string provider, string providerId, CancellationToken ct = default) =>
await repository.GetByProviderAsync(provider.Trim(), providerId.Trim(), ct);
public async Task<int> RegisterLocalAsync(string name, string email, string phone, string? passwordHash, int? clientId = null, CancellationToken ct = default) =>
await RegisterAsync(name, email, phone, "local", null, passwordHash, clientId, ct);
public async Task<int> RegisterOAuthAsync(string name, string email, string? phone, string provider, string providerId, int? clientId = null, CancellationToken ct = default) =>
await RegisterAsync(name, email, phone, provider, providerId, null, clientId, ct);
public async Task LinkOAuthAsync(PortalUser user, string provider, string providerId, string? displayName = null, string? email = null, CancellationToken ct = default)
{
user.Name = string.IsNullOrWhiteSpace(displayName) ? user.Name : displayName.Trim();
user.Email = string.IsNullOrWhiteSpace(email) ? user.Email : email.Trim();
if (string.IsNullOrWhiteSpace(user.PasswordHash))
{
user.Provider = provider.Trim();
user.ProviderId = providerId.Trim();
}
await repository.UpdateAsync(user, ct);
}
public async Task AttachClientAsync(PortalUser user, int clientId, CancellationToken ct = default)
{
user.ClientId = clientId;
await repository.UpdateAsync(user, ct);
}
private async Task<int> RegisterAsync(string name, string email, string? phone, string provider, string? providerId, string? passwordHash, int? clientId, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(name))
throw new ValidationException("이름을 입력하세요.");
if (string.IsNullOrWhiteSpace(email))
throw new ValidationException("이메일을 입력하세요.");
var user = new PortalUser
{
ClientId = clientId,
Name = name.Trim(),
Email = email.Trim(),
Phone = string.IsNullOrWhiteSpace(phone) ? null : phone.Trim(),
Provider = provider,
ProviderId = providerId,
PasswordHash = passwordHash,
CreatedAt = DateTime.UtcNow
};
return await repository.CreateAsync(user, ct);
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace TaxBaik.Domain.Entities;
public class PortalUser
{
public int Id { get; set; }
public int? ClientId { get; set; }
public string Email { get; set; } = "";
public string Name { get; set; } = "";
public string? Phone { get; set; }
public string Provider { get; set; } = "local";
public string? ProviderId { get; set; }
public string? PasswordHash { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,12 @@
namespace TaxBaik.Domain.Interfaces;
using TaxBaik.Domain.Entities;
public interface IPortalUserRepository
{
Task<PortalUser?> GetByIdAsync(int id, CancellationToken ct = default);
Task<PortalUser?> GetByEmailAsync(string email, CancellationToken ct = default);
Task<PortalUser?> GetByProviderAsync(string provider, string providerId, CancellationToken ct = default);
Task<int> CreateAsync(PortalUser user, CancellationToken ct = default);
Task UpdateAsync(PortalUser user, CancellationToken ct = default);
}
@@ -0,0 +1,64 @@
namespace TaxBaik.Infrastructure.Repositories;
using Dapper;
using TaxBaik.Domain.Entities;
using TaxBaik.Domain.Interfaces;
public class PortalUserRepository(IDbConnectionFactory connectionFactory) : BaseRepository(connectionFactory), IPortalUserRepository
{
public async Task<PortalUser?> GetByIdAsync(int id, CancellationToken ct = default)
{
using var conn = Conn();
return await conn.QueryFirstOrDefaultAsync<PortalUser>(
@"SELECT id, client_id, email, name, phone, provider, provider_id, password_hash, created_at
FROM portal_users
WHERE id = @Id",
new { Id = id });
}
public async Task<PortalUser?> GetByEmailAsync(string email, CancellationToken ct = default)
{
using var conn = Conn();
return await conn.QueryFirstOrDefaultAsync<PortalUser>(
@"SELECT id, client_id, email, name, phone, provider, provider_id, password_hash, created_at
FROM portal_users
WHERE email = @Email",
new { Email = email });
}
public async Task<PortalUser?> GetByProviderAsync(string provider, string providerId, CancellationToken ct = default)
{
using var conn = Conn();
return await conn.QueryFirstOrDefaultAsync<PortalUser>(
@"SELECT id, client_id, email, name, phone, provider, provider_id, password_hash, created_at
FROM portal_users
WHERE provider = @Provider AND provider_id = @ProviderId",
new { Provider = provider, ProviderId = providerId });
}
public async Task<int> CreateAsync(PortalUser user, CancellationToken ct = default)
{
using var conn = Conn();
return await conn.QueryFirstAsync<int>(
@"INSERT INTO portal_users (client_id, email, name, phone, provider, provider_id, password_hash, created_at)
VALUES (@ClientId, @Email, @Name, @Phone, @Provider, @ProviderId, @PasswordHash, NOW())
RETURNING id",
user);
}
public async Task UpdateAsync(PortalUser user, CancellationToken ct = default)
{
using var conn = Conn();
await conn.ExecuteAsync(
@"UPDATE portal_users
SET client_id = @ClientId,
email = @Email,
name = @Name,
phone = @Phone,
provider = @Provider,
provider_id = @ProviderId,
password_hash = @PasswordHash
WHERE id = @Id",
user);
}
}
@@ -0,0 +1,9 @@
@page "/portal/external-callback"
@model TaxBaik.Web.Pages.Portal.ExternalCallbackModel
@{
ViewData["Title"] = "포털 인증 처리";
}
<section class="container py-5">
<div class="alert alert-info">인증을 처리하는 중입니다...</div>
</section>
@@ -0,0 +1,97 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Portal;
public class ExternalCallbackModel : PageModel
{
private readonly PortalUserService _portalUserService;
private readonly ClientService _clientService;
public ExternalCallbackModel(PortalUserService portalUserService, ClientService clientService)
{
_portalUserService = portalUserService;
_clientService = clientService;
}
public async Task<IActionResult> OnGetAsync(string provider)
{
var external = await HttpContext.AuthenticateAsync(PortalOAuthDefaults.ExternalScheme);
if (external?.Principal is null)
return RedirectToPage("/Portal/Login");
var email = external.Principal.FindFirstValue(ClaimTypes.Email);
var name = external.Principal.FindFirstValue(ClaimTypes.Name) ?? "고객";
var providerId = external.Principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
if (string.IsNullOrWhiteSpace(providerId))
return RedirectToPage("/Portal/Login");
var existing = await _portalUserService.GetByProviderAsync(provider, providerId);
if (existing is null && !string.IsNullOrWhiteSpace(email))
{
existing = await _portalUserService.GetByEmailAsync(email);
if (existing is null)
{
int? clientId = null;
var linkedClient = await _clientService.GetByEmailAsync(email);
if (linkedClient is null && !string.IsNullOrWhiteSpace(external.Principal.FindFirstValue("phone")))
linkedClient = await _clientService.GetByPhoneAsync(external.Principal.FindFirstValue("phone")!);
if (linkedClient is not null)
clientId = linkedClient.Id;
await _portalUserService.RegisterOAuthAsync(
name,
email,
external.Principal.FindFirstValue("phone") ?? "",
provider,
providerId,
clientId);
existing = await _portalUserService.GetByEmailAsync(email);
}
else if (!string.Equals(existing.Provider, provider, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(existing.ProviderId, providerId, StringComparison.OrdinalIgnoreCase))
{
await _portalUserService.LinkOAuthAsync(existing, provider, providerId, name, email);
}
}
if (existing is not null && !existing.ClientId.HasValue && !string.IsNullOrWhiteSpace(email))
{
var linkedClient = await _clientService.GetByEmailAsync(email);
if (linkedClient is null && !string.IsNullOrWhiteSpace(external.Principal.FindFirstValue("phone")))
linkedClient = await _clientService.GetByPhoneAsync(external.Principal.FindFirstValue("phone")!);
if (linkedClient is not null)
{
await _portalUserService.AttachClientAsync(existing, linkedClient.Id);
existing.ClientId = linkedClient.Id;
}
}
if (existing is null)
return RedirectToPage("/Portal/Login");
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, existing.Id.ToString()),
new(ClaimTypes.Name, existing.Name),
new(ClaimTypes.Email, existing.Email),
new("portal_user_id", existing.Id.ToString())
};
if (existing.ClientId.HasValue)
claims.Add(new("client_id", existing.ClientId.Value.ToString()));
await HttpContext.SignInAsync(
PortalAuthDefaults.Scheme,
new ClaimsPrincipal(new ClaimsIdentity(claims, PortalAuthDefaults.Scheme)),
new AuthenticationProperties { IsPersistent = true });
await HttpContext.SignOutAsync(PortalOAuthDefaults.ExternalScheme);
return RedirectToPage("/Portal/Index");
}
}
+34
View File
@@ -0,0 +1,34 @@
@page "/portal"
@model TaxBaik.Web.Pages.Portal.IndexModel
@{
ViewData["Title"] = "고객 포털";
ViewData["Description"] = "고객이 신고 일정, 상담 요약, 중요 알림을 확인하는 전용 포털입니다.";
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/taxbaik/portal";
}
<section class="container py-5">
<div class="row g-4 align-items-start">
<div class="col-lg-7">
<p class="text-uppercase text-muted small mb-2">Portal</p>
<h1 class="display-6 fw-bold mb-3">고객 포털</h1>
<p class="lead text-muted mb-4">
신고 일정, 상담 요약, 승인된 알림을 확인할 수 있는 전용 공간입니다.
</p>
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-dark" href="/taxbaik/portal/login">로그인</a>
<a class="btn btn-outline-dark" href="/taxbaik/portal/register">회원가입</a>
</div>
</div>
<div class="col-lg-5">
<div class="p-4 bg-light border rounded-3">
<h2 class="h5 fw-bold mb-3">제공 예정 기능</h2>
<ul class="mb-0 text-muted">
<li>본인 신고 일정 확인</li>
<li>상담 요약 열람</li>
<li>중요 알림 수신</li>
<li>관리자 승인 범위 내 정보 제공</li>
</ul>
</div>
</div>
</div>
</section>
+13
View File
@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Portal;
[Authorize(AuthenticationSchemes = PortalAuthDefaults.Scheme)]
public class IndexModel : PageModel
{
public void OnGet()
{
}
}
+40
View File
@@ -0,0 +1,40 @@
@page "/portal/login"
@model TaxBaik.Web.Pages.Portal.LoginModel
@{
ViewData["Title"] = "고객 포털 로그인";
ViewData["Description"] = "고객 포털 로그인 페이지입니다.";
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/taxbaik/portal/login";
}
<section class="container py-5" style="max-width: 560px;">
<h1 class="h3 fw-bold mb-4">고객 포털 로그인</h1>
<div class="alert alert-secondary">
포털 인증은 다음 단계에서 이메일/비밀번호와 소셜 로그인으로 연결됩니다.
</div>
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
{
<div class="alert alert-danger">@Model.ErrorMessage</div>
}
<form method="post" class="vstack gap-3">
<div>
<label class="form-label">이메일</label>
<input class="form-control" asp-for="Email" />
</div>
<div>
<label class="form-label">비밀번호</label>
<input class="form-control" asp-for="Password" type="password" />
</div>
<button class="btn btn-dark" type="submit">로그인</button>
</form>
<div class="d-grid gap-2 mt-4">
<form method="post" asp-page-handler="Google">
<button class="btn btn-outline-dark w-100" type="submit">Google로 로그인</button>
</form>
<form method="post" asp-page-handler="Naver">
<button class="btn btn-outline-success w-100" type="submit">Naver로 로그인</button>
</form>
<form method="post" asp-page-handler="Kakao">
<button class="btn btn-outline-warning w-100" type="submit">Kakao로 로그인</button>
</form>
</div>
</section>
+56
View File
@@ -0,0 +1,56 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Portal;
public class LoginModel : PageModel
{
private readonly PortalAuthService _portalAuthService;
[BindProperty]
public string Email { get; set; } = "";
[BindProperty]
public string Password { get; set; } = "";
[BindProperty]
public string? ErrorMessage { get; set; }
public LoginModel(PortalAuthService portalAuthService)
{
_portalAuthService = portalAuthService;
}
public void OnGet()
{
}
public async Task<IActionResult> OnPostAsync()
{
if (string.IsNullOrWhiteSpace(Email) || string.IsNullOrWhiteSpace(Password))
{
ErrorMessage = "이메일과 비밀번호를 입력하세요.";
return Page();
}
var signedIn = await _portalAuthService.SignInAsync(Email, Password);
if (!signedIn)
{
ErrorMessage = "로그인 정보를 확인할 수 없습니다.";
return Page();
}
return RedirectToPage("/Portal/Index");
}
public IActionResult OnPostGoogle() => Challenge(BuildProps("google"), PortalOAuthDefaults.GoogleScheme);
public IActionResult OnPostNaver() => Challenge(BuildProps("naver"), PortalOAuthDefaults.NaverScheme);
public IActionResult OnPostKakao() => Challenge(BuildProps("kakao"), PortalOAuthDefaults.KakaoScheme);
private static AuthenticationProperties BuildProps(string provider) =>
new() { RedirectUri = $"/taxbaik/portal/external-callback?provider={provider}" };
}
+35
View File
@@ -0,0 +1,35 @@
@page "/portal/register"
@model TaxBaik.Web.Pages.Portal.RegisterModel
@{
ViewData["Title"] = "고객 포털 회원가입";
ViewData["Description"] = "고객 포털 회원가입 페이지입니다.";
ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/taxbaik/portal/register";
}
<section class="container py-5" style="max-width: 640px;">
<h1 class="h3 fw-bold mb-4">고객 포털 회원가입</h1>
<div class="alert alert-secondary">
가입 흐름은 다음 단계에서 이메일/전화번호 검증과 소셜 로그인으로 확장합니다.
</div>
<form method="post" class="row g-3">
<div class="col-md-6">
<label class="form-label">이름</label>
<input class="form-control" asp-for="Name" />
</div>
<div class="col-md-6">
<label class="form-label">연락처</label>
<input class="form-control" asp-for="Phone" />
</div>
<div class="col-12">
<label class="form-label">이메일</label>
<input class="form-control" asp-for="Email" />
</div>
<div class="col-12">
<label class="form-label">비밀번호</label>
<input class="form-control" asp-for="Password" type="password" />
</div>
<div class="col-12">
<button class="btn btn-dark" type="submit">가입하기</button>
</div>
</form>
</section>
@@ -0,0 +1,75 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.Services;
using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Portal;
public class RegisterModel : PageModel
{
private readonly PortalUserService _portalUserService;
private readonly ClientService _clientService;
[BindProperty]
public string Name { get; set; } = "";
[BindProperty]
public string Phone { get; set; } = "";
[BindProperty]
public string Email { get; set; } = "";
[BindProperty]
public string Password { get; set; } = "";
[BindProperty]
public string? ErrorMessage { get; set; }
public RegisterModel(PortalUserService portalUserService, ClientService clientService)
{
_portalUserService = portalUserService;
_clientService = clientService;
}
public void OnGet()
{
}
public async Task<IActionResult> OnPostAsync()
{
if (string.IsNullOrWhiteSpace(Name) || string.IsNullOrWhiteSpace(Email))
{
ErrorMessage = "이름과 이메일을 입력하세요.";
return Page();
}
if (string.IsNullOrWhiteSpace(Password) || Password.Length < 8)
{
ErrorMessage = "비밀번호는 8자 이상이어야 합니다.";
return Page();
}
var existing = await _portalUserService.GetByEmailAsync(Email);
if (existing is not null)
{
ErrorMessage = "이미 등록된 이메일입니다.";
return Page();
}
int? clientId = null;
var linkedClient = await _clientService.GetByEmailAsync(Email);
if (linkedClient is null && !string.IsNullOrWhiteSpace(Phone))
linkedClient = await _clientService.GetByPhoneAsync(Phone);
if (linkedClient is not null)
clientId = linkedClient.Id;
await _portalUserService.RegisterLocalAsync(
Name,
Email,
Phone,
PortalAuthService.HashPassword(Password),
clientId: clientId);
return RedirectToPage("/Portal/Login");
}
}
@@ -0,0 +1,7 @@
namespace TaxBaik.Web.Services;
public static class PortalAuthDefaults
{
public const string Scheme = "PortalCookie";
public const string CookieName = "TaxBaik.Portal.Auth";
}
+14
View File
@@ -0,0 +1,14 @@
namespace TaxBaik.Web.Services;
public sealed class PortalAuthOptions
{
public ExternalProviderOptions Google { get; set; } = new();
public ExternalProviderOptions Naver { get; set; } = new();
public ExternalProviderOptions Kakao { get; set; } = new();
public sealed class ExternalProviderOptions
{
public string ClientId { get; set; } = "";
public string ClientSecret { get; set; } = "";
}
}
+70
View File
@@ -0,0 +1,70 @@
namespace TaxBaik.Web.Services;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
public class PortalAuthService(
IHttpContextAccessor httpContextAccessor,
PortalUserService portalUserService)
{
private static readonly PasswordHasher<PortalUser> Hasher = new();
public async Task<bool> SignInAsync(string email, string password, CancellationToken ct = default)
{
var httpContext = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HTTP context is unavailable.");
var user = await portalUserService.GetByEmailAsync(email, ct);
if (user is null)
return false;
if (string.IsNullOrWhiteSpace(user.PasswordHash))
return false;
var verify = Hasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (verify == PasswordVerificationResult.Failed)
return false;
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Name, user.Name),
new(ClaimTypes.Email, user.Email),
new("portal_user_id", user.Id.ToString())
};
if (user.ClientId.HasValue)
claims.Add(new("client_id", user.ClientId.Value.ToString()));
var identity = new ClaimsIdentity(claims, PortalAuthDefaults.Scheme);
var principal = new ClaimsPrincipal(identity);
await httpContext.SignInAsync(
PortalAuthDefaults.Scheme,
principal,
new AuthenticationProperties
{
IsPersistent = true
});
return true;
}
public static string HashPassword(string password)
{
var tempUser = new PortalUser();
return Hasher.HashPassword(tempUser, password);
}
public async Task SignOutAsync()
{
var httpContext = httpContextAccessor.HttpContext
?? throw new InvalidOperationException("HTTP context is unavailable.");
await httpContext.SignOutAsync(PortalAuthDefaults.Scheme);
}
}
@@ -0,0 +1,9 @@
namespace TaxBaik.Web.Services;
public static class PortalOAuthDefaults
{
public const string ExternalScheme = "PortalExternal";
public const string GoogleScheme = "PortalGoogle";
public const string NaverScheme = "PortalNaver";
public const string KakaoScheme = "PortalKakao";
}
+1
View File
@@ -14,6 +14,7 @@
<ItemGroup>
<PackageReference Include="MudBlazor" Version="6.10.0" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.19.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
+15 -1
View File
@@ -19,13 +19,27 @@
},
"Telegram": {
"BotToken": "8679990909:AAGLLRUIAuEbYAZVGOYDu-UuTu4ihroEiX0",
"ChatId": "-5585148480",
"ChatId": "-5434691215",
"InquiryChatId": "-5434691215",
"SystemChatId": "-5585148480"
},
"Admin": {
"PasswordResetToken": "dev-reset-token-12345"
},
"Authentication": {
"Google": {
"ClientId": "",
"ClientSecret": ""
},
"Naver": {
"ClientId": "",
"ClientSecret": ""
},
"Kakao": {
"ClientId": "",
"ClientSecret": ""
}
},
"SiteSettings": {
"PhoneNumber": "010-4122-8268",
"EmailAddress": "taxbaik5668@gmail.com",
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS portal_users (
id SERIAL PRIMARY KEY,
client_id INT NULL REFERENCES clients(id) ON DELETE SET NULL,
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
phone VARCHAR(50),
provider VARCHAR(30) NOT NULL DEFAULT 'local',
provider_id VARCHAR(200),
password_hash TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_portal_users_provider
ON portal_users(provider, provider_id);