From e2472b7ea1dafc33066b8bac1835ebe21c9ab0ba Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Sun, 28 Jun 2026 18:39:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(portal):=20=EA=B3=A0=EA=B0=9D=20=ED=8F=AC?= =?UTF-8?q?=ED=84=B8=20=EC=9D=B8=EC=A6=9D=EA=B3=BC=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B8=B0=EB=B0=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 ++ .../Services/PortalUserService.cs | 59 +++++++++++ TaxBaik.Domain/Entities/PortalUser.cs | 14 +++ .../Interfaces/IPortalUserRepository.cs | 12 +++ .../Repositories/PortalUserRepository.cs | 64 ++++++++++++ .../Pages/Portal/ExternalCallback.cshtml | 9 ++ .../Pages/Portal/ExternalCallback.cshtml.cs | 97 +++++++++++++++++++ TaxBaik.Web/Pages/Portal/Index.cshtml | 34 +++++++ TaxBaik.Web/Pages/Portal/Index.cshtml.cs | 13 +++ TaxBaik.Web/Pages/Portal/Login.cshtml | 40 ++++++++ TaxBaik.Web/Pages/Portal/Login.cshtml.cs | 56 +++++++++++ TaxBaik.Web/Pages/Portal/Register.cshtml | 35 +++++++ TaxBaik.Web/Pages/Portal/Register.cshtml.cs | 75 ++++++++++++++ TaxBaik.Web/Services/PortalAuthDefaults.cs | 7 ++ TaxBaik.Web/Services/PortalAuthOptions.cs | 14 +++ TaxBaik.Web/Services/PortalAuthService.cs | 70 +++++++++++++ TaxBaik.Web/Services/PortalOAuthDefaults.cs | 9 ++ TaxBaik.Web/TaxBaik.Web.csproj | 1 + TaxBaik.Web/appsettings.json | 16 ++- db/migrations/V016__CreatePortalUsers.sql | 14 +++ 20 files changed, 644 insertions(+), 1 deletion(-) create mode 100644 TaxBaik.Application/Services/PortalUserService.cs create mode 100644 TaxBaik.Domain/Entities/PortalUser.cs create mode 100644 TaxBaik.Domain/Interfaces/IPortalUserRepository.cs create mode 100644 TaxBaik.Infrastructure/Repositories/PortalUserRepository.cs create mode 100644 TaxBaik.Web/Pages/Portal/ExternalCallback.cshtml create mode 100644 TaxBaik.Web/Pages/Portal/ExternalCallback.cshtml.cs create mode 100644 TaxBaik.Web/Pages/Portal/Index.cshtml create mode 100644 TaxBaik.Web/Pages/Portal/Index.cshtml.cs create mode 100644 TaxBaik.Web/Pages/Portal/Login.cshtml create mode 100644 TaxBaik.Web/Pages/Portal/Login.cshtml.cs create mode 100644 TaxBaik.Web/Pages/Portal/Register.cshtml create mode 100644 TaxBaik.Web/Pages/Portal/Register.cshtml.cs create mode 100644 TaxBaik.Web/Services/PortalAuthDefaults.cs create mode 100644 TaxBaik.Web/Services/PortalAuthOptions.cs create mode 100644 TaxBaik.Web/Services/PortalAuthService.cs create mode 100644 TaxBaik.Web/Services/PortalOAuthDefaults.cs create mode 100644 db/migrations/V016__CreatePortalUsers.sql diff --git a/.env.example b/.env.example index e3613b6..024164d 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/TaxBaik.Application/Services/PortalUserService.cs b/TaxBaik.Application/Services/PortalUserService.cs new file mode 100644 index 0000000..7abde7c --- /dev/null +++ b/TaxBaik.Application/Services/PortalUserService.cs @@ -0,0 +1,59 @@ +namespace TaxBaik.Application.Services; + +using TaxBaik.Domain.Entities; +using TaxBaik.Domain.Interfaces; + +public class PortalUserService(IPortalUserRepository repository) +{ + public async Task GetByEmailAsync(string email, CancellationToken ct = default) => + await repository.GetByEmailAsync(email.Trim(), ct); + + public async Task GetByProviderAsync(string provider, string providerId, CancellationToken ct = default) => + await repository.GetByProviderAsync(provider.Trim(), providerId.Trim(), ct); + + public async Task 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 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 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); + } +} diff --git a/TaxBaik.Domain/Entities/PortalUser.cs b/TaxBaik.Domain/Entities/PortalUser.cs new file mode 100644 index 0000000..707bfca --- /dev/null +++ b/TaxBaik.Domain/Entities/PortalUser.cs @@ -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; } +} diff --git a/TaxBaik.Domain/Interfaces/IPortalUserRepository.cs b/TaxBaik.Domain/Interfaces/IPortalUserRepository.cs new file mode 100644 index 0000000..af83aa7 --- /dev/null +++ b/TaxBaik.Domain/Interfaces/IPortalUserRepository.cs @@ -0,0 +1,12 @@ +namespace TaxBaik.Domain.Interfaces; + +using TaxBaik.Domain.Entities; + +public interface IPortalUserRepository +{ + Task GetByIdAsync(int id, CancellationToken ct = default); + Task GetByEmailAsync(string email, CancellationToken ct = default); + Task GetByProviderAsync(string provider, string providerId, CancellationToken ct = default); + Task CreateAsync(PortalUser user, CancellationToken ct = default); + Task UpdateAsync(PortalUser user, CancellationToken ct = default); +} diff --git a/TaxBaik.Infrastructure/Repositories/PortalUserRepository.cs b/TaxBaik.Infrastructure/Repositories/PortalUserRepository.cs new file mode 100644 index 0000000..138f909 --- /dev/null +++ b/TaxBaik.Infrastructure/Repositories/PortalUserRepository.cs @@ -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 GetByIdAsync(int id, CancellationToken ct = default) + { + using var conn = Conn(); + return await conn.QueryFirstOrDefaultAsync( + @"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 GetByEmailAsync(string email, CancellationToken ct = default) + { + using var conn = Conn(); + return await conn.QueryFirstOrDefaultAsync( + @"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 GetByProviderAsync(string provider, string providerId, CancellationToken ct = default) + { + using var conn = Conn(); + return await conn.QueryFirstOrDefaultAsync( + @"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 CreateAsync(PortalUser user, CancellationToken ct = default) + { + using var conn = Conn(); + return await conn.QueryFirstAsync( + @"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); + } +} diff --git a/TaxBaik.Web/Pages/Portal/ExternalCallback.cshtml b/TaxBaik.Web/Pages/Portal/ExternalCallback.cshtml new file mode 100644 index 0000000..fbaabfe --- /dev/null +++ b/TaxBaik.Web/Pages/Portal/ExternalCallback.cshtml @@ -0,0 +1,9 @@ +@page "/portal/external-callback" +@model TaxBaik.Web.Pages.Portal.ExternalCallbackModel +@{ + ViewData["Title"] = "포털 인증 처리"; +} + +
+
인증을 처리하는 중입니다...
+
diff --git a/TaxBaik.Web/Pages/Portal/ExternalCallback.cshtml.cs b/TaxBaik.Web/Pages/Portal/ExternalCallback.cshtml.cs new file mode 100644 index 0000000..9b4fcfa --- /dev/null +++ b/TaxBaik.Web/Pages/Portal/ExternalCallback.cshtml.cs @@ -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 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 + { + 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"); + } +} diff --git a/TaxBaik.Web/Pages/Portal/Index.cshtml b/TaxBaik.Web/Pages/Portal/Index.cshtml new file mode 100644 index 0000000..5d8f74b --- /dev/null +++ b/TaxBaik.Web/Pages/Portal/Index.cshtml @@ -0,0 +1,34 @@ +@page "/portal" +@model TaxBaik.Web.Pages.Portal.IndexModel +@{ + ViewData["Title"] = "고객 포털"; + ViewData["Description"] = "고객이 신고 일정, 상담 요약, 중요 알림을 확인하는 전용 포털입니다."; + ViewData["CanonicalUrl"] = $"{Request.Scheme}://{Request.Host}/taxbaik/portal"; +} + +
+
+
+

Portal

+

고객 포털

+

+ 신고 일정, 상담 요약, 승인된 알림을 확인할 수 있는 전용 공간입니다. +

+ +
+
+
+

제공 예정 기능

+
    +
  • 본인 신고 일정 확인
  • +
  • 상담 요약 열람
  • +
  • 중요 알림 수신
  • +
  • 관리자 승인 범위 내 정보 제공
  • +
+
+
+
+
diff --git a/TaxBaik.Web/Pages/Portal/Index.cshtml.cs b/TaxBaik.Web/Pages/Portal/Index.cshtml.cs new file mode 100644 index 0000000..8ec3edc --- /dev/null +++ b/TaxBaik.Web/Pages/Portal/Index.cshtml.cs @@ -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() + { + } +} diff --git a/TaxBaik.Web/Pages/Portal/Login.cshtml b/TaxBaik.Web/Pages/Portal/Login.cshtml new file mode 100644 index 0000000..924a0bf --- /dev/null +++ b/TaxBaik.Web/Pages/Portal/Login.cshtml @@ -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"; +} + +
+

고객 포털 로그인

+
+ 포털 인증은 다음 단계에서 이메일/비밀번호와 소셜 로그인으로 연결됩니다. +
+ @if (!string.IsNullOrWhiteSpace(Model.ErrorMessage)) + { +
@Model.ErrorMessage
+ } +
+
+ + +
+
+ + +
+ +
+
+
+ +
+
+ +
+
+ +
+
+
diff --git a/TaxBaik.Web/Pages/Portal/Login.cshtml.cs b/TaxBaik.Web/Pages/Portal/Login.cshtml.cs new file mode 100644 index 0000000..324e0cd --- /dev/null +++ b/TaxBaik.Web/Pages/Portal/Login.cshtml.cs @@ -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 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}" }; +} diff --git a/TaxBaik.Web/Pages/Portal/Register.cshtml b/TaxBaik.Web/Pages/Portal/Register.cshtml new file mode 100644 index 0000000..5052bdb --- /dev/null +++ b/TaxBaik.Web/Pages/Portal/Register.cshtml @@ -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"; +} + +
+

고객 포털 회원가입

+
+ 가입 흐름은 다음 단계에서 이메일/전화번호 검증과 소셜 로그인으로 확장합니다. +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
diff --git a/TaxBaik.Web/Pages/Portal/Register.cshtml.cs b/TaxBaik.Web/Pages/Portal/Register.cshtml.cs new file mode 100644 index 0000000..bca00f7 --- /dev/null +++ b/TaxBaik.Web/Pages/Portal/Register.cshtml.cs @@ -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 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"); + } +} diff --git a/TaxBaik.Web/Services/PortalAuthDefaults.cs b/TaxBaik.Web/Services/PortalAuthDefaults.cs new file mode 100644 index 0000000..7ca7759 --- /dev/null +++ b/TaxBaik.Web/Services/PortalAuthDefaults.cs @@ -0,0 +1,7 @@ +namespace TaxBaik.Web.Services; + +public static class PortalAuthDefaults +{ + public const string Scheme = "PortalCookie"; + public const string CookieName = "TaxBaik.Portal.Auth"; +} diff --git a/TaxBaik.Web/Services/PortalAuthOptions.cs b/TaxBaik.Web/Services/PortalAuthOptions.cs new file mode 100644 index 0000000..25f9bb9 --- /dev/null +++ b/TaxBaik.Web/Services/PortalAuthOptions.cs @@ -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; } = ""; + } +} diff --git a/TaxBaik.Web/Services/PortalAuthService.cs b/TaxBaik.Web/Services/PortalAuthService.cs new file mode 100644 index 0000000..7c456d2 --- /dev/null +++ b/TaxBaik.Web/Services/PortalAuthService.cs @@ -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 Hasher = new(); + + public async Task 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 + { + 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); + } +} diff --git a/TaxBaik.Web/Services/PortalOAuthDefaults.cs b/TaxBaik.Web/Services/PortalOAuthDefaults.cs new file mode 100644 index 0000000..9bfb446 --- /dev/null +++ b/TaxBaik.Web/Services/PortalOAuthDefaults.cs @@ -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"; +} diff --git a/TaxBaik.Web/TaxBaik.Web.csproj b/TaxBaik.Web/TaxBaik.Web.csproj index 6c05a86..f14e1ea 100644 --- a/TaxBaik.Web/TaxBaik.Web.csproj +++ b/TaxBaik.Web/TaxBaik.Web.csproj @@ -14,6 +14,7 @@ + diff --git a/TaxBaik.Web/appsettings.json b/TaxBaik.Web/appsettings.json index 4a87fa2..8777994 100644 --- a/TaxBaik.Web/appsettings.json +++ b/TaxBaik.Web/appsettings.json @@ -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", diff --git a/db/migrations/V016__CreatePortalUsers.sql b/db/migrations/V016__CreatePortalUsers.sql new file mode 100644 index 0000000..4133598 --- /dev/null +++ b/db/migrations/V016__CreatePortalUsers.sql @@ -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);