ASP.NET Core 로그인 IP 제한

  • 81 minutes to read

이 문서는 ASP.NET Core MVC 애플리케이션에 IP 제한 기능을 추가하는 방법을 설명합니다. 이 기능은 특정 테넌트에 대해 지정된 IP 주소 범위 내에서만 로그인을 허용하도록 제한합니다.

애 문서에 대한 유료 동영상 강좌는 다음 링크에 있습니다.

데브렉 - ASP.NET Core 8.0 MVC 미니 프로젝트 - 로그인 IP 제한 기능 구현

https://youtu.be/R4ctmSJNvPc

그림: 지정된 IP 범위에 포함된 사용자만 접근할 수 있도록 설정

Allowed IP Ranges

Allowed IP Ranges 목록에 등록된 사용자에게만 로그인을 허용하는 기능은, 인트라넷 기반 솔루션에서 IP를 기반으로 로그인 접근을 제한할 때 매우 유용합니다

강의 소개

ASP.NET Core 로그인 IP 제한

이 문서는 ASP.NET Core MVC 애플리케이션에서 IP 제한 기능을 구현하는 방법에 대해 상세하게 설명합니다. 특정 테넌트의 지정된 IP 주소 범위 내에서만 로그인을 허용하는 방법에 중점을 두고 있습니다. 각 섹션은 구현의 다양한 측면을 자세히 설명합니다:

  1. Blazor 기반 MVC 프로젝트: Blazor Server 8.0 프로젝트를 MVC와 Razor Pages와 함께 설정하는 단계를 개요합니다. 이는 프로젝트 생성, 인증 옵션, 필요 패키지 설치 및 Program.cs 파일 구성을 포함합니다.

  2. ASP.NET Core Identity 구성: ASP.NET Core Identity를 사용하여 사용자 인증 및 역할 기반 권한 부여를 구성하는 방법을 설명합니다. ApplicationUserApplicationRole 클래스를 사용하여 사용자와 역할을 사용자 지정하고 데이터베이스와 연결하는 방법을 살펴봅니다.

  3. 테넌트 정보: 테넌트의 정보를 저장하는 Tenants 테이블 생성, 테넌트 모델 클래스 정의, 데이터베이스 컨텍스트 업데이트, 초기 테넌트 데이터 자동 추가 등에 대해 설명합니다.

  4. IP 주소 테이블과 모델 클래스: IP 주소 범위와 관련된 정보를 저장하는 AllowedIPRanges 테이블 생성 및 모델 클래스 정의에 대해 설명합니다.

  5. 스캐폴딩으로 Login 페이지 추가: ASP.NET Core Identity Scaffolding을 사용하여 기본 제공된 로그인 페이지를 사용자 지정하는 방법을 안내합니다.

  6. 인증 및 권한 부여 규칙 설정: Razor Pages 애플리케이션에서 사용자의 인증 및 권한 부여 규칙을 구성하는 방법을 설명합니다.

  7. ApplicationUser.TenantId 추가: 다중 테넌트 시스템에서 각 사용자가 특정 테넌트에 속하도록 TenantId 컬럼을 추가하는 방법을 설명합니다.

  8. 로그인 페이지 코드 비하인드: 사용자의 로그인 후 IP 주소 제한을 검사하고, 허용된 IP에서만 접근을 허용하는 로직에 대해 설명합니다.

  9. RestrictedAccess.cshtml: 제한된 접근을 알리는 뷰 페이지에 대해 설명합니다.

  10. AllowedIPRanges CRUD 구현: AllowedIPRanges 테이블에 대한 CRUD 기능을 구현하고, 관리자가 IP 범위를 쉽게 관리할 수 있는 인터페이스를 생성하는 방법을 소개합니다.

  11. 구성 기반 IP 제한: appsettings.json 파일을 통해 IP 제한 기능을 구성하고, 로그인 시 IP 정보를 수집하는 방법을 소개합니다.

이 문서는 보안, 예외 처리, 로깅 등을 고려하여 추가적인 개선이 필요한 실제 구현에 있어서 중요한 지침을 제공합니다.

Blazor 기반 MVC 프로젝트

Blazor Server 8.0 프로젝트에서 인증을 포함하여 설정하고, 그 안에서 MVC와 Razor Pages를 사용할 수 있도록 Program.cs 파일을 설정하는 단계는 다음과 같습니다.

https://youtu.be/cNyx_h2Wc0o

  1. Blazor Server 프로젝트 생성: Blazor Server 프로젝트를 생성할 때 인증 옵션을 포함하여 프로젝트를 설정합니다. Visual Studio에서 프로젝트를 새로 생성할 때 'Authentication' 옵션을 선택하고, 원하는 인증 방식(예: Individual User Accounts)을 선택합니다.

  2. 필요한 패키지 설치: MVC 및 Razor Pages를 사용하기 위해 필요한 NuGet 패키지를 설치합니다. 일반적으로 Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation 패키지가 필요할 수 있습니다.

    dotnet add package Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation
    
  3. Program.cs 파일 수정: Blazor Server 프로젝트의 Program.cs 파일을 열고 다음 단계를 따릅니다.

    • 서비스 추가: MVC와 Razor Pages 관련 서비스를 추가합니다.

      var builder = WebApplication.CreateBuilder(args);
      
      // Add services to the container.
      builder.Services.AddRazorPages();
      builder.Services.AddServerSideBlazor();
      builder.Services.AddControllersWithViews(); // MVC를 위한 서비스 추가
      
      // ... 기타 필요한 서비스 구성 ...
      
    • 앱 파이프라인 구성: 애플리케이션의 HTTP 요청 파이프라인을 구성합니다. MVC와 Razor Pages 엔드포인트를 추가합니다.

      var app = builder.Build();
      
      // Configure the HTTP request pipeline.
      if (!app.Environment.IsDevelopment())
      {
          app.UseExceptionHandler("/Error");
          // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
          app.UseHsts();
      }
      
      app.UseHttpsRedirection();
      
      app.UseStaticFiles();
      
      app.UseRouting();
      
      app.UseAuthentication();
      app.UseAuthorization();
      
      app.MapBlazorHub();
      app.MapFallbackToPage("/_Host");
      
      app.MapRazorPages();  // Razor Pages 엔드포인트 추가
      app.MapControllers(); // MVC 컨트롤러 엔드포인트 추가
      
      app.Run();
      
  4. Razor Pages 및 MVC 컨트롤러 추가: 프로젝트에 필요한 Razor Pages 및 MVC 컨트롤러를 추가합니다. Visual Studio에서는 'Add' -> 'New Scaffolded Item...'을 선택하여 Razor Pages 또는 MVC Controller를 추가할 수 있습니다.

  5. 앱 실행 및 테스트: 설정을 마친 후, 애플리케이션을 실행하고 Razor Pages 및 MVC 컨트롤러가 정상적으로 작동하는지 확인합니다.

이 단계를 통해 Blazor Server 애플리케이션에서 MVC와 Razor Pages를 함께 사용할 수 있게 되며, 다양한 페이지 및 데이터 처리 방식을 혼합하여 사용할 수 있습니다. 인증을 포함한 설정으로, 보안이 강화된 애플리케이션을 구축할 수 있습니다.

코드: Program.cs

using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using VisualAcademy.Components;
using VisualAcademy.Components.Account;
using VisualAcademy.Data;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddControllersWithViews();
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();

builder.Services.AddCascadingAuthenticationState();
builder.Services.AddScoped<IdentityUserAccessor>();
builder.Services.AddScoped<IdentityRedirectManager>();
builder.Services.AddScoped<AuthenticationStateProvider, IdentityRevalidatingAuthenticationStateProvider>();

builder.Services.AddAuthentication(options =>
    {
        options.DefaultScheme = IdentityConstants.ApplicationScheme;
        options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
    })
    .AddIdentityCookies();

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddIdentityCore<ApplicationUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddSignInManager()
    .AddDefaultTokenProviders();

builder.Services.AddSingleton<IEmailSender<ApplicationUser>, IdentityNoOpEmailSender>();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseMigrationsEndPoint();
}
else
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();

app.UseStaticFiles();
app.UseAntiforgery();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();
app.MapRazorPages();
app.MapDefaultControllerRoute();

// Add additional endpoints required by the Identity /Account Razor components.
app.MapAdditionalIdentityEndpoints();

app.Run();

ASP.NET Core Identity 구성

이 섹션에서는 ASP.NET Core Identity를 사용하여 사용자 인증 및 역할 기반 권한 부여를 구성하는 방법을 설명합니다. ApplicationUserApplicationRole 클래스를 사용하여 사용자 및 역할을 사용자 지정하고, 이들을 데이터베이스와 연결하는 방법을 살펴봅니다.

https://youtu.be/mbnkBbn1zDs

Identity 서비스 구성

builder.Services.AddIdentity 메서드를 사용하여 ApplicationDbContext에 Identity 서비스를 추가하고 구성합니다. 이 코드는 Program.cs 파일에 위치합니다.

builder.Services.AddIdentity<ApplicationUser, ApplicationRole>(
    options =>
    {
        options.SignIn.RequireConfirmedAccount = false; // 계정 확인을 요구하지 않음
        options.SignIn.RequireConfirmedEmail = false;  // 이메일 확인을 요구하지 않음

        // 비밀번호 정책 설정 (예: 숫자 포함 여부)
        // options.Password.RequireDigit = false; 
    })
    .AddEntityFrameworkStores<ApplicationDbContext>() // Identity를 위한 EF Core 저장소 지정
    .AddDefaultTokenProviders(); // 토큰 생성을 위한 기본 제공자 사용

다음 코드는 Blazor Web App 기반으로 프로젝트 만들었을 때의 코드 블록입니다. AddRoles 확장 메서드를 추가로 호출합니다.

builder.Services.AddIdentityCore<ApplicationUser>(
    options =>
    {
        options.SignIn.RequireConfirmedAccount = false; // 계정 확인을 요구하지 않음
        options.SignIn.RequireConfirmedEmail = false; // 이메일 확인을 요구하지 않음

        // 비밀번호 정책 설정 (예: 숫자 포함 여부)
        // options.Password.RequireDigit = false; 
    })
    .AddRoles<ApplicationRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddSignInManager()
    .AddDefaultTokenProviders();
  • ApplicationUser: 사용자 정보를 나타내는 클래스로, Identity 시스템에서 사용자를 관리합니다. 필요에 따라 추가 속성을 이 클래스에 추가하여 사용자 정보를 확장할 수 있습니다.

  • ApplicationRole: 역할 정보를 나타내는 클래스로, 사용자의 역할 및 권한 관리에 사용됩니다. 역할 기반의 권한 부여를 구현할 때 중요합니다.

  • options: Identity의 다양한 설정을 구성할 수 있는 옵션입니다. 예를 들어, 계정 및 이메일 확인 요구 여부, 비밀번호 정책 등을 설정할 수 있습니다.

ApplicationDbContext 클래스

ApplicationDbContext 클래스는 Entity Framework Core를 사용하여 Identity 관련 데이터를 데이터베이스에 저장하고 관리하는 데 사용됩니다. 이 클래스는 IdentityDbContext를 상속받으며, ApplicationUserApplicationRole을 사용하여 사용자 및 역할을 관리합니다.

public class ApplicationDbContext : IdentityDbContext<ApplicationUser, ApplicationRole, string>
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }
    // ... 필요한 DbSets 및 기타 구성 ...
}

이 클래스는 OnModelCreating 메서드를 오버라이드하여 사용자 지정 모델 구성을 추가할 수 있는 기능을 제공합니다. 이를 통해 테이블 이름, 제약 조건, 관계 등을 사용자 지정할 수 있습니다.

사용법 및 주의 사항

  • 안정성 및 보안: 애플리케이션의 보안 요구 사항에 따라 Identity 옵션을 적절히 구성해야 합니다. 예를 들어, 실제 배포 환경에서는 계정 및 이메일 확인을 요구하는 것이 좋습니다.

  • 데이터베이스 마이그레이션: Identity 서비스 및 사용자 지정 클래스를 추가하거나 수정한 후에는 데이터베이스 스키마에 변경 사항을 반영하기 위해 EF Core 마이그레이션을 생성하고 적용해야 합니다.

  • 역할 기반 권한 부여: ApplicationRole을 사용하여 역할을 정의하고, [Authorize(Roles = "RoleName")] 어트리뷰트를 사용하여 특정 역할에 속한 사용자만 특정 액션 또는 컨트롤러에 접근할 수 있도록 제한할 수 있습니다.

이러한 설정을 통해 강력하고 유연한 사용자 인증 및 권한 부여 시스템을 구축할 수 있으며, 다양한 보안 및 사용자 관리 요구 사항을 충족할 수 있습니다.

Identity 관련 클래스 코드

ApplicationUser.cs

using Microsoft.AspNetCore.Identity;

namespace VisualAcademy.Data;

// Add profile data for application users by adding properties to the ApplicationUser class
public class ApplicationUser : IdentityUser
{
    /// <summary>
    /// 사용자의 이름
    /// </summary>
    public string? FirstName { get; set; }

    /// <summary>
    /// 사용자의 성
    /// </summary>
    public string? LastName { get; set; }

    /// <summary>
    /// 사용자의 시간대
    /// </summary>
    public string? Timezone { get; set; }

    /// <summary>
    /// 주소
    /// </summary>
    public string? Address { get; set; }

    /// <summary>
    /// 성별
    /// </summary>
    public string? Gender { get; set; }

    /// <summary>
    /// 테넌트 ID
    /// </summary>
    public long TenantId { get; set; }
}

ApplicationRole.cs

using Microsoft.AspNetCore.Identity;

namespace VisualAcademy.Areas.Identity.Models;

/// <summary>
/// 사용자의 역할과 관련된 정보를 정의합니다.
/// </summary>
public class ApplicationRole : IdentityRole
{
    /// <summary>
    /// 역할에 대한 설명
    /// </summary>
    public string? Description { get; set; }
}

ApplicationDbContext.cs

using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace VisualAcademy.Data
{
    public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) 
        : IdentityDbContext<ApplicationUser, ApplicationRole, string>(options)
    {
    }
}

Program.cs

builder.Services.AddIdentityCore<ApplicationUser>(
    options =>
    {
        options.SignIn.RequireConfirmedAccount = false; // 계정 확인을 요구하지 않음
        options.SignIn.RequireConfirmedEmail = false; // 이메일 확인을 요구하지 않음

        // 비밀번호 정책 설정 (예: 숫자 포함 여부)
        // options.Password.RequireDigit = false; 
    })
    .AddRoles<ApplicationRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddSignInManager()
    .AddDefaultTokenProviders();

데이터베이스 마이그레이션 및 업데이트

Identity 서비스 및 사용자 지정 클래스에 속성을 추가하거나 변경한 후에는 변경 사항을 데이터베이스 스키마에 반영하기 위해 Entity Framework Core 마이그레이션을 생성하고 적용해야 합니다. 이 과정은 코드의 변경 사항을 데이터베이스에 동기화하여, 애플리케이션이 제대로 작동할 수 있도록 합니다.

https://youtu.be/Vug0qBrEG50

마이그레이션 생성

  1. 패키지 관리자 콘솔 이용(Visual Studio): Visual Studio를 사용하고 있다면, 패키지 관리자 콘솔(Package Manager Console)을 열고 다음 명령어를 입력하여 새로운 마이그레이션을 추가합니다.

    Add-Migration <MigrationName>
    

    <MigrationName>은 이 마이그레이션의 이름으로, 변경 내용을 설명하는 이름을 선택하는 것이 좋습니다. 예: AddUserProperties

  2. .NET CLI 이용: Visual Studio를 사용하지 않거나 다른 개발 환경에서 작업하는 경우 .NET CLI를 사용하여 마이그레이션을 추가할 수 있습니다. 터미널을 열고 다음 명령어를 실행하세요.

    dotnet ef migrations add <MigrationName>
    

    여기서도 <MigrationName>은 이 마이그레이션에 대한 설명적인 이름입니다.

데이터베이스 업데이트

새 마이그레이션을 생성한 후, 데이터베이스를 업데이트하여 변경 사항을 적용해야 합니다.

  1. 패키지 관리자 콘솔 이용(Visual Studio)

    Update-Database
    
  2. .NET CLI 이용

    dotnet ef database update
    

이 명령은 마이그레이션을 데이터베이스에 적용하여 모델의 변경 사항과 데이터베이스 스키마가 일치하도록 합니다.

주의 사항 및 추가 정보

  • 마이그레이션을 생성하고 데이터베이스를 업데이트하기 전에 항상 코드를 백업하고 적절한 버전 관리를 사용하세요.
  • 마이그레이션 과정에서 오류가 발생하면, 오류 메시지를 주의 깊게 읽고 해결 방법을 찾으세요.
  • 개발 중에는 데이터베이스 스키마를 자주 변경할 수 있으므로, 데이터베이스 마이그레이션과 업데이트는 흔히 발생하는 작업입니다.

이러한 단계를 통해 ASP.NET Core Identity 구성을 완료하고, 사용자 및 역할에 관련된 추가 속성들을 데이터베이스에 성공적으로 적용할 수 있습니다.

여러 개의 DbContext가 있을 경우 Context 지정

애플리케이션에 여러 개의 DbContext가 있는 경우, 마이그레이션을 생성하거나 데이터베이스를 업데이트할 때 명확하게 대상 DbContext를 지정해야 합니다. 이를 통해 Entity Framework Core가 어떤 컨텍스트의 스키마를 변경할지 정확히 알 수 있습니다.

마이그레이션 생성 시 DbContext 지정

  1. 패키지 관리자 콘솔 이용(Visual Studio): 패키지 관리자 콘솔에서 -Context 옵션을 사용하여 특정 DbContext를 지정할 수 있습니다.

    Add-Migration <MigrationName> -Context <DbContextName>
    

    <DbContextName>에는 마이그레이션을 추가하려는 DbContext의 이름을 입력합니다.

  2. .NET CLI 이용: .NET CLI를 사용하여 마이그레이션을 추가할 때는 --context 플래그를 사용하여 대상 DbContext를 지정합니다.

    dotnet ef migrations add <MigrationName> --context <DbContextName>
    

데이터베이스 업데이트 시 DbContext 지정

데이터베이스를 업데이트할 때도 마찬가지로, 어떤 DbContext에 대한 업데이트인지 지정해야 합니다.

  1. 패키지 관리자 콘솔 이용(Visual Studio)

    Update-Database -Context <DbContextName>
    
  2. .NET CLI 이용

    dotnet ef database update --context <DbContextName>
    

DbContext 지정 시 주의사항

  • DbContext 이름은 해당 클래스의 이름입니다. 예를 들어, ApplicationDbContext를 지정하려면 <DbContextName> 자리에 ApplicationDbContext를 입력하면 됩니다.
  • 여러 DbContext를 사용하는 경우 각각에 대해 별도의 마이그레이션 폴더가 생성됩니다. 이를 통해 각 컨텍스트의 마이그레이션을 구분하고 관리할 수 있습니다.
  • 마이그레이션을 적용할 때 올바른 DbContext를 지정하는 것이 중요합니다. 잘못된 DbContext를 지정하면 예상치 못한 데이터베이스 스키마 변경이 발생할 수 있습니다.

이러한 지침을 따르면 여러 DbContext를 가진 복잡한 애플리케이션에서도 Entity Framework Core 마이그레이션을 정확하게 관리하고 적용할 수 있습니다.

SQL Server 데이터베이스 프로젝트로 인증 관련 테이블 가져오기

애플리케이션의 ASP.NET Core Identity 관련 테이블을 효과적으로 관리하고 버전을 관리하려면, SQL Server 데이터베이스 프로젝트를 사용하는 것이 좋습니다. Visual Studio 2022에서 SQL Server 데이터베이스 프로젝트를 만들고, 기존 데이터베이스의 스키마를 프로젝트로 가져오는 과정은 다음과 같습니다.

https://youtu.be/1q0Ox4qM_34

  1. 데이터베이스 프로젝트 생성:

    • Visual Studio 2022에서 새 프로젝트를 만들고, 'SQL Server Database Project'를 선택합니다.
    • 프로젝트 이름을 'VisualAcademy.SqlServer'로 설정하고, 원하는 위치에 프로젝트를 생성합니다.
  2. 기존 데이터베이스에서 스키마 가져오기:

    • 프로젝트를 마우스 오른쪽 버튼으로 클릭하고, 'Import' > 'Database...'를 선택합니다.
    • 데이터베이스 연결 대화 상자에서, Identity 관련 테이블이 포함된 데이터베이스(예: 'VisualAcademy')에 연결합니다.
    • 연결이 성공하면, 'Start Import'를 클릭하여 데이터베이스 스키마를 프로젝트로 가져옵니다.
  3. 테이블 및 기타 개체 검토:

    • 가져오기가 완료되면, 프로젝트 내에서 Tables, Views, Stored Procedures 등 다양한 데이터베이스 개체를 확인할 수 있습니다.
    • Identity 관련 테이블(AspNetUsers, AspNetRoles 등)이 정상적으로 가져와졌는지 확인합니다.
  4. 프로젝트를 통한 스키마 관리:

    • 이제 VisualAcademy.SqlServer 프로젝트를 통해 데이터베이스 스키마를 관리할 수 있습니다.
    • 테이블 구조를 변경하거나 새로운 Stored Procedure를 추가하는 등의 작업을 프로젝트에서 진행한 후, 이를 데이터베이스에 적용할 수 있습니다.
  5. 버전 관리:

    • SQL Server 데이터베이스 프로젝트는 소스 코드와 마찬가지로 버전 관리 시스템에 포함될 수 있습니다.
    • 스키마 변경 사항을 추적하고, 팀과 협업하며, 변경 이력을 관리하는 데 유용합니다.

이 과정을 통해, ASP.NET Core Identity와 관련된 데이터베이스 스키마를 효율적으로 관리하고, 애플리케이션의 데이터베이스 구조를 안정적으로 유지할 수 있습니다. 데이터베이스 프로젝트는 복잡한 애플리케이션에 있어서 중요한 자산이 될 수 있으며, 개발 과정에서 큰 이점을 제공합니다.

AspNetUsers.sql

CREATE TABLE [dbo].[AspNetUsers] (
    [Id]                   NVARCHAR (450)     NOT NULL,
    [UserName]             NVARCHAR (256)     NULL,
    [NormalizedUserName]   NVARCHAR (256)     NULL,
    [Email]                NVARCHAR (256)     NULL,
    [NormalizedEmail]      NVARCHAR (256)     NULL,
    [EmailConfirmed]       BIT                NOT NULL,
    [PasswordHash]         NVARCHAR (MAX)     NULL,
    [SecurityStamp]        NVARCHAR (MAX)     NULL,
    [ConcurrencyStamp]     NVARCHAR (MAX)     NULL,
    [PhoneNumber]          NVARCHAR (MAX)     NULL,
    [PhoneNumberConfirmed] BIT                NOT NULL,
    [TwoFactorEnabled]     BIT                NOT NULL,
    [LockoutEnd]           DATETIMEOFFSET (7) NULL,
    [LockoutEnabled]       BIT                NOT NULL,
    [AccessFailedCount]    INT                NOT NULL,
    [Address]              NVARCHAR (MAX)     NULL,
    [FirstName]            NVARCHAR (MAX)     NULL,
    [Gender]               NVARCHAR (MAX)     NULL,
    [LastName]             NVARCHAR (MAX)     NULL,
    [TenantId]             BIGINT             DEFAULT (CONVERT([bigint],(0))) NOT NULL,
    [Timezone]             NVARCHAR (MAX)     NULL,
    CONSTRAINT [PK_AspNetUsers] PRIMARY KEY CLUSTERED ([Id] ASC)
);


GO
CREATE NONCLUSTERED INDEX [EmailIndex]
    ON [dbo].[AspNetUsers]([NormalizedEmail] ASC);


GO
CREATE UNIQUE NONCLUSTERED INDEX [UserNameIndex]
    ON [dbo].[AspNetUsers]([NormalizedUserName] ASC) WHERE ([NormalizedUserName] IS NOT NULL);

AspNetRoles.sql

CREATE TABLE [dbo].[AspNetRoles] (
    [Id]               NVARCHAR (450) NOT NULL,
    [Name]             NVARCHAR (256) NULL,
    [NormalizedName]   NVARCHAR (256) NULL,
    [ConcurrencyStamp] NVARCHAR (MAX) NULL,
    [Description]      NVARCHAR (MAX) NULL,
    CONSTRAINT [PK_AspNetRoles] PRIMARY KEY CLUSTERED ([Id] ASC)
);


GO
CREATE UNIQUE NONCLUSTERED INDEX [RoleNameIndex]
    ON [dbo].[AspNetRoles]([NormalizedName] ASC) WHERE ([NormalizedName] IS NOT NULL);

테넌트 정보

https://youtu.be/qpw169hhHE4

테넌트 테이블 구조

먼저 Tenants 테이블을 생성하여 각 테넌트의 정보를 저장합니다. 이 테이블은 테넌트의 ID와 이름을 저장합니다.

Tenants.sql

CREATE TABLE Tenants (
    Id BIGINT PRIMARY KEY IDENTITY(1,1),  -- 자동 증가하는 기본 키
    Name NVARCHAR(100)                    -- 테넌트의 이름
);

테넌트 모델 클래스

TenantModel 클래스를 정의하여, Tenants 테이블의 데이터를 개체로 관리할 수 있습니다.

public class TenantModel
{
    public long Id { get; set; }
    public string Name { get; set; }
}

데이터베이스 컨텍스트 업데이트

ApplicationDbContext 클래스에 Tenants 테이블에 대한 DbSet을 추가하여 Entity Framework Core를 사용하여 데이터베이스와 상호 작용할 수 있도록 합니다.

ApplicationDbContext.cs

public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
    // ... 기존 DbContext 코드 ...

    public DbSet<TenantModel> Tenants { get; set; }
}

이 강의에서 최종 완성된 ApplicationDbContext의 미리 보기 코드는 다음과 같습니다.

using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using VisualAcademy.Models;

namespace VisualAcademy.Data;

public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
    : IdentityDbContext<ApplicationUser, ApplicationRole, string>(options)
{
    public DbSet<TenantModel> Tenants { get; set; }

    public DbSet<AllowedIPRange> AllowedIPRanges { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // 테넌트 초기 데이터 설정
        modelBuilder.Entity<TenantModel>().HasData(
            new TenantModel { Id = 1, Name = "Tenant 1" },
            new TenantModel { Id = 2, Name = "Tenant 2" }
        );
    }
}

초기 테넌트 데이터 자동 추가

이 단계에서는 ASP.NET Core MVC 애플리케이션에 자동으로 초기 테넌트 데이터를 추가하는 기능을 구현합니다. 이를 통해 애플리케이션 시작 시 Tenants 테이블이 비어 있으면 "Tenant 1"과 "Tenant 2"를 자동으로 추가합니다.

이번 단계는 반드시 실행할 필요는 없습니다. 직접 테이블에 데이터를 수작업으로 추가하고 연습해도 됩니다.

https://youtu.be/pqUVtXvXF2Q

Program.cs 수정

Program.cs 파일에서 애플리케이션의 시작 시 데이터베이스 컨텍스트를 구성하고 초기화 코드를 추가합니다.

var builder = WebApplication.CreateBuilder(args);

// ... 기존 구성 코드 ...

// ApplicationDbContext 서비스 추가
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

var app = builder.Build();

// ... 기존 애플리케이션 구성 코드 ...

// 데이터베이스 초기화
InitializeDatabase(app);

app.Run();

void InitializeDatabase(IApplicationBuilder app)
{
    using (var scope = app.ApplicationServices.CreateScope())
    {
        var services = scope.ServiceProvider;
        var context = services.GetRequiredService<ApplicationDbContext>();

        // 테넌트 데이터 확인 및 초기화
        if (!context.Tenants.Any())
        {
            context.Tenants.AddRange(
                new TenantModel { Name = "Tenant 1" },
                new TenantModel { Name = "Tenant 2" }
            );
            context.SaveChanges();
        }
    }
}

ApplicationDbContext 수정

ApplicationDbContextOnModelCreating 메서드 내에서 초기 테넌트 데이터를 삽입하는 로직을 추가합니다.

public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
    // ... 기존 코드 ...

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // 테넌트 초기 데이터 설정
        modelBuilder.Entity<TenantModel>().HasData(
            new TenantModel { Id = 1, Name = "Tenant 1" },
            new TenantModel { Id = 2, Name = "Tenant 2" }
        );
    }
}

데이터베이스 마이그레이션 확인

코드 수정 후 새로운 마이그레이션을 추가하고 데이터베이스를 업데이트해야 합니다. 이는 Visual Studio의 패키지 관리자 콘솔 또는 .NET CLI를 사용하여 수행할 수 있습니다.

Add-Migration InitializeTenants
Update-Database

이제 애플리케이션이 시작될 때마다 Tenants 테이블에 "Tenant 1"과 "Tenant 2"라는 이름을 가진 두 개의 테넌트가 자동으로 삽입됩니다. 이 단계를 통해 데이터베이스 스키마와 초기 데이터가 항상 최신 상태로 유지됩니다.

IP 주소 테이블과 모델 클래스

https://youtu.be/E6WJxJQAsnk

AllowedIPRanges 테이블

AllowedIPRanges 테이블을 생성하기 위한 SQL 구문입니다. 이 테이블은 IP 주소 범위, 테넌트 ID 등의 정보를 저장합니다.

AllowedIPRanges.sql

CREATE TABLE AllowedIPRanges (
    ID INT PRIMARY KEY IDENTITY(1,1),       -- 자동 증가하는 기본 키
    StartIPRange VARCHAR(15),               -- IP 범위 시작 주소
    EndIPRange VARCHAR(15),                 -- IP 범위 끝 주소
    Description NVarChar(Max),              -- IP 범위에 대한 설명
    CreateDate DATETIME Default(GetDate()), -- 범위가 추가된 날짜
    TenantId BIGINT                         -- 테넌트 ID
);

애플리케이션 시작할 때 테이블 동적 생성

ASP.NET Core 애플리케이션에서 데이터베이스 스키마를 동적으로 관리하는 것은 유연성과 확장성을 제공합니다. VisualAcademy.Infrastructures 네임스페이스 아래의 DefaultSchemaEnhancerCreateAllowedIPRangesTable 클래스는 애플리케이션 시작 시 AllowedIPRanges 테이블을 데이터베이스에 자동으로 생성하는 기능을 수행합니다. 이 문서는 해당 클래스의 구현과 ASP.NET Core 8 및 이전 버전에서의 사용 방법을 설명합니다.

코드: DefaultSchemaEnhancerCreateAllowedIPRangesTable.cs

using System;
using Microsoft.Data.SqlClient; // using System.Data.SqlClient;

namespace VisualAcademy.Infrastructures
{
    public class DefaultSchemaEnhancerCreateAllowedIPRangesTable
    {
        private string _defaultConnectionString;

        public DefaultSchemaEnhancerCreateAllowedIPRangesTable(string defaultConnectionString)
        {
            _defaultConnectionString = defaultConnectionString;
        }

        public void EnhanceDefaultDatabase()
        {
            CreateAllowedIPRangesTableIfNotExists();
        }

        private void CreateAllowedIPRangesTableIfNotExists()
        {
            using (SqlConnection connection = new SqlConnection(_defaultConnectionString))
            {
                connection.Open();

                SqlCommand cmdCheck = new SqlCommand(@"
                    SELECT COUNT(*) 
                    FROM INFORMATION_SCHEMA.TABLES 
                    WHERE TABLE_SCHEMA = 'dbo' 
                    AND TABLE_NAME = 'AllowedIPRanges'", connection);

                int tableCount = (int)cmdCheck.ExecuteScalar();

                if (tableCount == 0)
                {
                    SqlCommand cmdCreateTable = new SqlCommand(@"
                        CREATE TABLE AllowedIPRanges (
                            ID INT PRIMARY KEY IDENTITY(1,1),
                            StartIPRange VARCHAR(15),
                            EndIPRange VARCHAR(15),
                            Description NVarChar(Max),
                            CreateDate DATETIME Default(GetDate()),
                            TenantId BIGINT
                        )", connection);

                    cmdCreateTable.ExecuteNonQuery();
                }

                connection.Close();
            }
        }
    }
}

Program.cs 코드 조각

using VisualAcademy.Infrastructures;
using Microsoft.EntityFrameworkCore;
using System;

var builder = WebApplication.CreateBuilder(args);

// 컨테이너에 서비스 추가.
// MVC, Razor 페이지, 컨트롤러 등 필요한 다른 서비스를 구성합니다.
// ...

// 애플리케이션을 빌드합니다.
var app = builder.Build();

// 기본 연결 문자열 검색
var defaultConnectionString = builder.Configuration.GetConnectionString("DefaultConnection");

// AllowedIPRanges 테이블이 존재하지 않는 경우 생성
var defaultSchemaEnhancer = new DefaultSchemaEnhancerCreateAllowedIPRangesTable(defaultConnectionString);
defaultSchemaEnhancer.EnhanceDefaultDatabase();

// HTTP 요청 파이프라인 구성.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
    app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthorization();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

app.Run();

AllowedIPRange 모델 클래스

AllowedIPRange.cs

#nullable disable
namespace VisualAcademy.Models;

public class AllowedIPRange
{
    public int Id { get; set; }
    public string StartIPRange { get; set; }
    public string EndIPRange { get; set; }
    public string Description { get; set; }
    public DateTime CreateDate { get; set; }
    public long TenantId { get; set; }
}

데이터베이스 컨텍스트 업데이트

ApplicationDbContext 클래스에 AllowedIPRanges 테이블에 대한 DbSet을 추가하여 Entity Framework Core를 사용하여 데이터베이스와 상호 작용할 수 있도록 합니다.

ApplicationDbContext.cs

public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
    // ... 기존 DbContext 코드 ...

    public DbSet<AllowedIPRange> AllowedIPRanges { get; set; }
}

스캐폴딩으로 Login 페이지 추가

ASP.NET Core Identity Scaffolding은 기존 Identity 시스템을 사용자 지정할 수 있게 해주는 강력한 기능입니다. 이를 사용하여 기본 제공된 로그인 페이지를 재정의할 수 있습니다. 다음 단계를 따라 Login.cshtml, Register.cshtml 페이지를 재정의하는 방법을 설명합니다.

https://youtu.be/h_GGFMj5Tws

  1. Identity Scaffolding 추가:

    • Visual Studio를 사용하는 경우: 솔루션 탐색기에서 프로젝트를 우클릭하고 'Add' -> 'New Scaffolded Item...'을 선택합니다. 'Identity'를 선택하고 'Add'를 클릭합니다.

    • .NET CLI를 사용하는 경우: 프로젝트 디렉터리에서 다음 명령을 실행합니다:

      dotnet aspnet-codegenerator identity -dc ProjectNamespace.ApplicationDbContext
      
  2. 필요한 파일 선택:

    • 'ADD'를 클릭하여 Identity를 위한 파일을 선택합니다. 'Override all files'를 선택하거나 개별적으로 'Account/Login'을 선택합니다. 'Register' 페이지도 추가해주세요.
  3. 데이터 컨텍스트 클래스 지정:

    • 'Data context class' 드롭다운에서 ApplicationDbContext 클래스를 선택합니다.
  4. Scaffolding 실행:

    • 모든 설정을 마친 후 'Add'를 클릭하여 Scaffolding을 실행합니다. 이 과정에서 Areas 폴더에 Identity 폴더가 생성되고, 그 안에 Login.cshtml 및 관련 코드 파일이 포함됩니다.
  5. Login.cshtml 수정:

    • 생성된 Areas/Identity/Pages/Account/Login.cshtml 파일을 열고 필요한 로그인 로직과 UI 변경을 적용합니다.
  6. 서비스 구성 수정 (Program.cs 또는 Startup.cs):

    • Identity Scaffolding을 사용한 후, Program.cs 또는 Startup.cs에서 Identity 서비스 구성을 업데이트하여 Scaffolding된 경로를 인식하도록 설정합니다.

      services.AddRazorPages(options =>
      {
          options.Conventions.AuthorizeAreaFolder("Identity", "/Account/Manage");
          options.Conventions.AuthorizeAreaPage("Identity", "/Account/Logout");
      });
      

위 단계를 통해 Login 페이지를 재정의하고, 로그인 로직을 사용자 지정하여 IP 주소 제한 기능 등 추가 기능을 구현할 수 있습니다. Scaffolding 기능을 사용하면 Identity 시스템의 다양한 부분을 더욱 유연하게 확장하고 사용자 지정할 수 있습니다.

인증 및 권한 부여 규칙 설정

이 단계에서는 ASP.NET Core의 Razor Pages 애플리케이션에서 사용자의 인증 및 권한 부여 규칙을 구성하는 방법을 설명합니다. 이는 사용자가 애플리케이션 내 민감한 페이지에 액세스할 때 적절한 인증 수준을 갖추고 있는지 확인하는 데 중요하며, 사이트의 보안을 강화하고, 인증되지 않은 사용자가 중요한 페이지에 액세스하는 것을 방지하는 데 도움이 됩니다.

services.AddRazorPages(options =>
{
    options.Conventions.AuthorizeAreaFolder("Identity", "/Account/Manage");
    options.Conventions.AuthorizeAreaPage("Identity", "/Account/Logout");
});
  • services.AddRazorPages(): 이 메서드는 Razor Pages를 애플리케이션에 추가하며, 설정을 구성할 수 있는 옵션을 제공합니다. Razor Pages를 사용하는 애플리케이션에 필수적인 부분입니다.

  • options.Conventions: AddRazorPages 메서드에서 반환된 옵션을 사용하여 Razor Pages의 라우팅, 인증, 권한 부여 등의 규칙을 세밀하게 제어할 수 있습니다.

  • AuthorizeAreaFolder: 이 설정은 특정 영역(area)에 있는 폴더의 Razor Pages에 대해 인증을 요구하도록 설정합니다. 이 예에서 "Identity" 영역의 "/Account/Manage" 폴더에 있는 모든 페이지는 인증된 사용자에 의해서만 접근될 수 있도록 설정되었습니다. 사용자가 이 폴더 내의 페이지에 액세스하려고 시도할 때 로그인되어 있지 않다면 로그인 페이지로 리디렉션됩니다.

  • AuthorizeAreaPage: 이 설정은 특정 영역의 개별 페이지에 대해 인증을 요구합니다. 여기서는 "Identity" 영역의 "/Account/Logout" 페이지가 대상입니다. 이 설정은 로그아웃 페이지에 대한 접근을 인증된 사용자로 제한하는 데 사용됩니다.

이러한 설정을 통해 애플리케이션 내에서 사용자의 권한을 효과적으로 관리할 수 있으며, 특정 페이지나 리소스에 대한 접근을 제어하는 데 필수적인 역할을 합니다. 이는 사용자가 애플리케이션을 안전하고 효율적으로 사용할 수 있도록 보장하는 데 중요한 부분입니다.

ApplicationUser.TenantId 추가

이 단계에서는 AspNetUsers 테이블을 확장하여 각 사용자가 특정 테넌트에 속하도록 TenantId 컬럼(속성)을 추가합니다. 이를 통해 다중 테넌트 시스템에서 사용자를 적절히 관리하고, IP 제한 기능을 테넌트별로 구분하여 적용할 수 있습니다.

https://youtu.be/rulglpmJPQY

ApplicationUser 클래스 수정

앞 단계에서 이미 이 내용을 적용했으면 다음 단계로 넘어갑니다.

ApplicationUser 클래스는 사용자 정보를 나타내며, Identity 시스템에서 AspNetUsers 테이블에 해당합니다. 이 클래스에 TenantId 속성을 추가하여 각 사용자가 특정 테넌트에 속하도록 합니다.

public class ApplicationUser : IdentityUser
{
    // ... 기존의 다른 속성들 ...

    // 사용자가 속한 테넌트의 ID
    public long TenantId { get; set; }
}

public class ApplicationRole : IdentityRole
{
    public string? Description { get; set; }
}

데이터베이스 마이그레이션

클래스에 속성을 추가한 후, 데이터베이스에 해당 변경을 반영하도록 마이그레이션을 생성하고 적용해야 합니다.

  1. 마이그레이션 생성:

    Add-Migration AddTenantIdToAspNetUsers
    
  2. 데이터베이스 업데이트:

    Update-Database
    

기본 테넌트 설정

모든 새 사용자에 대해 기본 TenantId를 설정하려면, 사용자 등록 또는 생성 로직에 기본값을 지정합니다. 예를 들어, 사용자를 등록하는 로직에 다음과 같이 추가할 수 있습니다.

// 사용자 등록 메서드 내부
var user = new ApplicationUser
{
    // ... 기타 사용자 정보 설정 ...
    TenantId = 1 // 기본값으로 1번 테넌트 ID를 설정
};

var result = await _userManager.CreateAsync(user, model.Password);
var user = CreateUser();

if (user != null) 
{ 
    user.TenantId = 1;
}

await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
var result = await _userManager.CreateAsync(user, Input.Password);

이를 통해 ApplicationUser의 모든 인스턴스가 데이터베이스에 TenantId 값과 함께 저장되며, 기본값으로 1번 테넌트에 속하게 됩니다. 실제 시스템에서는 사용자가 속한 테넌트를 결정하는 로직을 구현하여 TenantId를 동적으로 할당해야 할 수 있습니다.

이 단계를 통해 다중 테넌트 시스템의 구조를 개선하고, 각 사용자와 테넌트 간의 관계를 명확하게 설정할 수 있습니다. 사용자가 로그인할 때 TenantId를 참조하여 해당 사용자에 대한 IP 제한을 테넌트별로 구분하여 적용할 수 있게 됩니다.

EmailSender 서비스

ASP.NET Core Identity는 사용자 등록, 로그인, 로그아웃 등의 기능을 제공하는 라이브러리입니다. Identity 시스템은 사용자가 등록할 때나 비밀번호를 재설정할 때 이메일 확인과 같은 작업을 처리할 수 있는 기능을 제공합니다. 이를 위해 Microsoft.AspNetCore.Identity.UI.Services.IEmailSender 인터페이스를 구현한 서비스가 필요합니다.

EmailSender 클래스는 Microsoft.AspNetCore.Identity.UI.Services.IEmailSender 인터페이스를 구현합니다. 이 서비스는 실제 이메일을 보내는 로직을 포함하며, 예시 코드에서는 이 로직이 구현되지 않았기 때문에 실제 이메일을 보내지 않습니다. 실제 애플리케이션에서는 SMTP 서버를 사용하거나 SendGrid, MailGun 같은 이메일 서비스를 사용하여 이메일을 보내는 코드로 채워야 합니다.

using Microsoft.AspNetCore.Identity.UI.Services;

namespace VisualAcademy.Services
{
    public class EmailSender : IEmailSender
    {
        public Task SendEmailAsync(string email, string subject, string htmlMessage)
        {
            // TODO: 실제 이메일 전송 로직 구현
            return Task.CompletedTask;
        }
    }
}

Program.cs에서 EmailSender 서비스 등록

ASP.NET Core에서 서비스는 Dependency Injection (DI)을 통해 등록하고 사용됩니다. Program.cs 파일에서 EmailSender 서비스를 DI 컨테이너에 등록하여, 애플리케이션 전체에서 Microsoft.AspNetCore.Identity.UI.Services.IEmailSender 인터페이스가 필요할 때 EmailSender 인스턴스를 사용하도록 설정합니다.

using VisualAcademy.Services;
using Microsoft.AspNetCore.Identity.UI.Services;

var builder = WebApplication.CreateBuilder(args);

// ... 기존 구성 코드 ...

// EmailSender 서비스를 Microsoft.AspNetCore.Identity.UI.Services.IEmailSender로 등록
builder.Services.AddTransient<Microsoft.AspNetCore.Identity.UI.Services.IEmailSender, EmailSender>();

var app = builder.Build();

// ... 기존 애플리케이션 구성 코드 ...

app.Run();

설명

  • AddTransient: 이 메서드는 서비스의 새 인스턴스를 각 요청마다 생성하도록 설정합니다. 이메일 전송과 같이 상태가 없고, 요청마다 새로운 인스턴스가 필요한 경우에 적합합니다.
  • Microsoft.AspNetCore.Identity.UI.Services.IEmailSender: 이 인터페이스는 SendEmailAsync 메서드를 정의합니다. 이 메서드는 이메일을 보내는 데 사용됩니다.
  • EmailSender: 이 클래스는 Microsoft.AspNetCore.Identity.UI.Services.IEmailSender 인터페이스의 구현체로, 실제 이메일 전송 로직을 포함해야 합니다.

주의사항

  • 이 코드는 실제 이메일을 전송하지 않습니다. 실제 운영 환경에서 사용하려면 이메일 서비스 제공자(SMTP, SendGrid, MailGun 등)를 통해 이메일을 보내는 로직을 구현해야 합니다.
  • 보안상의 이유로, 이메일 서비스 제공자의 API 키나 SMTP 서버의 자격 증명과 같은 중요 정보는 애플리케이션 설정 파일이나 환경 변수에 안전하게 저장하고 접근해야 합니다.

이제 EmailSender 서비스를 사용하여 ASP.NET Core Identity의 이메일 관련 기능(예: 사용자 등록 시 이메일 확인, 비밀번호 재설정)을 구현할 수 있습니다.

로그인 페이지 코드 비하인드

로그인 메서드 내에서 사용자 로그인 후 IP 주소 제한을 검사하고, 허용된 IP에서 접근하는 경우 Home/Index 페이지로 리디렉션하며, 그렇지 않은 경우 사용자를 RestrictedAccess 페이지로 리디렉션합니다.

다음 코드를 1차로 참고한 후 최종 소스는 강의 완성 소스를 참고하세요.

Login.cshtml.cs

// 로그인 메서드 예시
public async Task<IActionResult> Login(LoginViewModel model)
{
    if (ModelState.IsValid)
    {
        var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: false);
        if (result.Succeeded)
        {
            // 사용자 정보 검색
            var user = await _userManager.FindByEmailAsync(model.Email);
            var TenantId = user.TenantId;

            // 현재 IP 주소 검색
            string currentIP = HttpContext.Connection.RemoteIpAddress.ToString();
            // 로컬호스트 IPv6 주소인 '::1'을 '127.0.0.1'로 변환
            if (currentIP == "::1")
            {
                currentIP = "127.0.0.1";
            }

            // 로그인 메서드 내에 추가할 코드
            try
            {
                var ipParts = currentIP.Split('.');
                if (ipParts.Length == 4) // IPv4 주소인지 확인
                {
                    // 마지막 옥텟을 1로 설정하여 StartIPRange 계산
                    ipParts[3] = "1";
                    string startIPRange = string.Join(".", ipParts);

                    // 마지막 옥텟을 255로 설정하여 EndIPRange 계산
                    ipParts[3] = "255";
                    string endIPRange = string.Join(".", ipParts);

                    // 사용자 이메일에서 도메인 부분만 추출
                    string emailDomain = model.Email.Substring(model.Email.IndexOf('@') + 1);

                    // 동일한 StartIPRange와 EndIPRange를 가진 엔트리가 이미 있는지 확인
                    var existingIPRange = await _context.AllowedIPRanges
                        .FirstOrDefaultAsync(ip => ip.StartIPRange == startIPRange && ip.EndIPRange == endIPRange && ip.TenantId == user.TenantId);

                    // 동일한 범위가 존재하지 않는 경우에만 새 범위 추가
                    if (existingIPRange == null)
                    {
                        var newIPRange = new AllowedIPRange
                        {
                            StartIPRange = startIPRange,
                            EndIPRange = endIPRange,
                            Description = emailDomain, // 사용자 이메일 도메인으로 설명 설정
                            CreateDate = DateTime.Now,
                            TenantId = user.TenantId // 현재 로그인한 사용자의 TenantId 사용
                        };
                        _context.AllowedIPRanges.Add(newIPRange);
                        await _context.SaveChangesAsync();
                    }
                }
            }
            catch (Exception ex)
            {
                // 예외 로깅 또는 사용자에게 친절한 에러 메시지 표시
                // 예: _logger.LogError("An error occurred: {0}", ex.ToString());
                // 사용자에게는 특정 에러 메시지를 반환하거나, 에러 페이지로 리디렉션할 수 있습니다.
            }

            // IP 주소 허용 검사
            bool isAllowed = await CheckIPAllowed(TenantId, currentIP);

            if (!isAllowed)
            {
                // 허용되지 않은 IP 주소인 경우, RestrictedAccess 뷰로 리디렉션
                return RedirectToAction("RestrictedAccess");
            }
            
            // 허용된 IP 주소인 경우, Home/Index로 리디렉션
            return RedirectToAction("Index", "Home");
        }
        // 실패 처리...
    }
    // 뷰 반환...
}

// IP 허용 검사 메서드 구현
private async Task<bool> CheckIPAllowed(long TenantId, string currentIP)
{
    var ipRangeList = await _context.AllowedIPRanges
                                    .Where(r => r.TenantId == TenantId)
                                    .ToListAsync();
    
    // 특정 테넌트ID에 해당하는 등록된 허용IP주소가 없으면 제한을 걸지 않고 허용
    if (!ipRangeList.Any())
    {
        return true; // 등록된 허용된 IP 주소가 없으면 모든 접속을 허용
    }

    foreach (var range in ipRangeList)
    {
        if (IsIPInRange(currentIP, range.StartIPRange, range.EndIPRange))
        {
            return true; // 현재 IP가 허용된 범위 내에 있으면 true 반환
        }
    }

    return false; // 허용된 범위에 없으면 false 반환
}

// IP 범위 확인 메서드
private bool IsIPInRange(string currentIP, string startIP, string endIP)
{
    var addr = IPAddress.Parse(currentIP);
    var lowerBound = IPAddress.Parse(startIP);
    var upperBound = IPAddress.Parse(endIP);

    byte[] addrBytes = addr.GetAddressBytes();
    byte[] lowerBytes = lowerBound.GetAddressBytes();
    byte[] upperBytes = upperBound.GetAddressBytes();

    bool lowerBoundCheck = true;
    bool upperBoundCheck = true;

    for (int i = 0; i < addrBytes.Length && (lowerBoundCheck || upperBoundCheck); i++)
    {
        if (lowerBoundCheck)
        {
            if (addrBytes[i] < lowerBytes[i])
            {
                return false;
            }
            else if (addrBytes[i] > lowerBytes[i])
            {
                lowerBoundCheck = false;
            }
        }

        if (upperBoundCheck)
        {
            if (addrBytes[i] > upperBytes[i])
            {
                return false;
            }
            else if (addrBytes[i] < upperBytes[i])
            {
                upperBoundCheck = false;
            }
        }
    }
    return true;
}

로그인 페이지에 대한 전체 코드 샘플은 다음과 같습니다.

Login.cshtml.cs

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
using VisualAcademy.Data;
using Microsoft.EntityFrameworkCore;
using VisualAcademy.Models;
using System.Net;

namespace VisualAcademy.Areas.Identity.Pages.Account
{
    public class LoginModel : PageModel
    {
        private readonly SignInManager<ApplicationUser> _signInManager;
        private readonly UserManager<ApplicationUser> _userManager;
        private readonly ILogger<LoginModel> _logger;
        private readonly ApplicationDbContext _context;

        public LoginModel(SignInManager<ApplicationUser> signInManager, UserManager<ApplicationUser> userManager, ILogger<LoginModel> logger, ApplicationDbContext context)
        {
            _signInManager = signInManager;
            _userManager = userManager;
            _logger = logger;
            _context = context;
        }

        /// <summary>
        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
        ///     directly from your code. This API may change or be removed in future releases.
        /// </summary>
        [BindProperty]
        public InputModel Input { get; set; }

        /// <summary>
        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
        ///     directly from your code. This API may change or be removed in future releases.
        /// </summary>
        public IList<AuthenticationScheme> ExternalLogins { get; set; }

        /// <summary>
        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
        ///     directly from your code. This API may change or be removed in future releases.
        /// </summary>
        public string ReturnUrl { get; set; }

        /// <summary>
        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
        ///     directly from your code. This API may change or be removed in future releases.
        /// </summary>
        [TempData]
        public string ErrorMessage { get; set; }

        /// <summary>
        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
        ///     directly from your code. This API may change or be removed in future releases.
        /// </summary>
        public class InputModel
        {
            /// <summary>
            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
            ///     directly from your code. This API may change or be removed in future releases.
            /// </summary>
            [Required]
            [EmailAddress]
            public string Email { get; set; }

            /// <summary>
            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
            ///     directly from your code. This API may change or be removed in future releases.
            /// </summary>
            [Required]
            [DataType(DataType.Password)]
            public string Password { get; set; }

            /// <summary>
            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
            ///     directly from your code. This API may change or be removed in future releases.
            /// </summary>
            [Display(Name = "Remember me?")]
            public bool RememberMe { get; set; }
        }

        public async Task OnGetAsync(string returnUrl = null)
        {
            if (!string.IsNullOrEmpty(ErrorMessage))
            {
                ModelState.AddModelError(string.Empty, ErrorMessage);
            }

            returnUrl ??= Url.Content("~/");

            // Clear the existing external cookie to ensure a clean login process
            await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);

            ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();

            ReturnUrl = returnUrl;
        }

        public async Task<IActionResult> OnPostAsync(string returnUrl = null)
        {
            returnUrl ??= Url.Content("~/");

            ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();

            if (ModelState.IsValid)
            {
                // This doesn't count login failures towards account lockout
                // To enable password failures to trigger account lockout, set lockoutOnFailure: true
                var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false);
                if (result.Succeeded)
                {
                    // 사용자 정보 검색
                    var user = await _userManager.FindByEmailAsync(Input.Email);
                    var TenantId = user.TenantId;

                    // 현재 IP 주소 검색
                    string currentIP = HttpContext.Connection.RemoteIpAddress.ToString();
                    // 로컬호스트 IPv6 주소인 '::1'을 '127.0.0.1'로 변환
                    if (currentIP == "::1")
                    {
                        currentIP = "127.0.0.1";
                    }

                    // 로그인 메서드 내에 추가할 코드
                    try
                    {
                        var ipParts = currentIP.Split('.');
                        if (ipParts.Length == 4) // IPv4 주소인지 확인
                        {
                            // 마지막 옥텟을 1로 설정하여 StartIPRange 계산
                            ipParts[3] = "1";
                            string startIPRange = string.Join(".", ipParts);

                            // 마지막 옥텟을 255로 설정하여 EndIPRange 계산
                            ipParts[3] = "255";
                            string endIPRange = string.Join(".", ipParts);

                            // 사용자 이메일에서 도메인 부분만 추출
                            string emailDomain = Input.Email.Substring(Input.Email.IndexOf('@') + 1);

                            // 동일한 StartIPRange와 EndIPRange를 가진 엔트리가 이미 있는지 확인
                            var existingIPRange = await _context.AllowedIPRanges
                                .FirstOrDefaultAsync(ip => ip.StartIPRange == startIPRange && ip.EndIPRange == endIPRange && ip.TenantId == user.TenantId);

                            // 동일한 범위가 존재하지 않는 경우에만 새 범위 추가
                            if (existingIPRange == null)
                            {
                                var newIPRange = new AllowedIPRange
                                {
                                    StartIPRange = startIPRange,
                                    EndIPRange = endIPRange,
                                    Description = emailDomain, // 사용자 이메일 도메인으로 설명 설정
                                    CreateDate = DateTime.Now,
                                    TenantId = user.TenantId // 현재 로그인한 사용자의 TenantId 사용
                                };
                                _context.AllowedIPRanges.Add(newIPRange);
                                await _context.SaveChangesAsync();
                            }
                        }
                    }
                    catch (Exception ex)
                    {
                        // 예외 로깅 또는 사용자에게 친절한 에러 메시지 표시
                        // 예: _logger.LogError("An error occurred: {0}", ex.ToString());
                        // 사용자에게는 특정 에러 메시지를 반환하거나, 에러 페이지로 리디렉션할 수 있습니다.
                    }

                    // IP 주소 허용 검사
                    bool isAllowed = await CheckIPAllowed(TenantId, currentIP);

                    if (!isAllowed)
                    {
                        // 허용되지 않은 IP 주소인 경우, RestrictedAccess 뷰로 리디렉션
                        return RedirectToPage("/RestrictedAccess");
                    }

                    // 허용된 IP 주소인 경우, Home/Index로 리디렉션
                    // return RedirectToAction("Index", "Home");

                    _logger.LogInformation("User logged in.");
                    return LocalRedirect(returnUrl);
                }
                if (result.RequiresTwoFactor)
                {
                    return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe });
                }
                if (result.IsLockedOut)
                {
                    _logger.LogWarning("User account locked out.");
                    return RedirectToPage("./Lockout");
                }
                else
                {
                    ModelState.AddModelError(string.Empty, "Invalid login attempt.");
                    return Page();
                }
            }

            // If we got this far, something failed, redisplay form
            return Page();
        }

        // IP 허용 검사 메서드 구현
        private async Task<bool> CheckIPAllowed(long TenantId, string currentIP)
        {
            var ipRangeList = await _context.AllowedIPRanges
                                            .Where(r => r.TenantId == TenantId)
                                            .ToListAsync();

            // 특정 테넌트ID에 해당하는 등록된 허용IP주소가 없으면 제한을 걸지 않고 허용
            if (!ipRangeList.Any())
            {
                return true; // 등록된 허용된 IP 주소가 없으면 모든 접속을 허용
            }

            foreach (var range in ipRangeList)
            {
                if (IsIPInRange(currentIP, range.StartIPRange, range.EndIPRange))
                {
                    return true; // 현재 IP가 허용된 범위 내에 있으면 true 반환
                }
            }

            return false; // 허용된 범위에 없으면 false 반환
        }

        // IP 범위 확인 메서드
        private bool IsIPInRange(string currentIP, string startIP, string endIP)
        {
            var addr = IPAddress.Parse(currentIP);
            var lowerBound = IPAddress.Parse(startIP);
            var upperBound = IPAddress.Parse(endIP);

            byte[] addrBytes = addr.GetAddressBytes();
            byte[] lowerBytes = lowerBound.GetAddressBytes();
            byte[] upperBytes = upperBound.GetAddressBytes();

            bool lowerBoundCheck = true;
            bool upperBoundCheck = true;

            for (int i = 0; i < addrBytes.Length && (lowerBoundCheck || upperBoundCheck); i++)
            {
                if (lowerBoundCheck)
                {
                    if (addrBytes[i] < lowerBytes[i])
                    {
                        return false;
                    }
                    else if (addrBytes[i] > lowerBytes[i])
                    {
                        lowerBoundCheck = false;
                    }
                }

                if (upperBoundCheck)
                {
                    if (addrBytes[i] > upperBytes[i])
                    {
                        return false;
                    }
                    else if (addrBytes[i] < upperBytes[i])
                    {
                        upperBoundCheck = false;
                    }
                }
            }
            return true;
        }
    }
}

RestrictedAccess.cshtml

제한된 접근을 알리는 뷰 페이지입니다.

코드: /Pages/RestrictedAccess.cshtml

@{
    Layout = null;
}

<!DOCTYPE html>
<html>
<head>
    <title>Access Restricted</title>
</head>
<body>
    <h1>Access Restricted</h1>
    <p>You will not be able to access this site from your current IP address in the future.</p>
</body>
</html>

ASP.NET Core Razor Pages에서 IP 주소 표현하기

ASP.NET Core Razor Pages에서 클라이언트의 IP 주소를 표현하는 방법은 간단합니다. HttpContext.Connection.RemoteIpAddress?.ToString() 명령을 사용하여 현재 요청을 보낸 클라이언트의 IP 주소를 가져올 수 있습니다. 이 IP 주소는 OnGet 메서드에서 가져와 ViewData나 모델 속성을 통해 Razor 페이지에 전달하여 화면에 표시할 수 있습니다. 이를 통해 사용자는 자신의 IP 주소를 확인할 수 있으며, 이는 특히 접근 제한 페이지나 보안 관련 기능 구현 시 유용합니다.

IP 주소까지 포함된 상태는 다음 코드를 사용하면 됩니다.

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Http;

namespace MemoEngine.Pages
{
    public class RestrictedAccessModel : PageModel
    {
        public string ClientIp { get; set; }

        public void OnGet()
        {
            ClientIp = HttpContext.Connection.RemoteIpAddress?.ToString();
            ViewData["ClientIp"] = ClientIp;
        }
    }
}
@page
@model MemoEngine.Pages.RestrictedAccessModel
@{
}

<h1>Access Restricted</h1>
<p>You will not be able to access this site from your current IP address in the future.</p>
<p>Your IP Address: <strong>@ViewData["ClientIp"]</strong></p>
<hr />
<a href="~/">Home</a>

이 문서는 사용자의 IP 주소를 기반으로 액세스를 제한하고, 해당 제한이 적용되는 경우 사용자에게 알리는 ASP.NET Core MVC 애플리케이션을 구축하는 방법에 대한 지침을 제공합니다. 실제 구현에서는 보안, 예외 처리, 로깅 등을 고려하여 추가적인 개선이 필요할 수 있습니다.

AllowedIPRanges CRUD 구현

AllowedIPRanges CRUD 구현

이 섹션에서는 AllowedIPRanges 테이블에 대한 CRUD(Create, Read, Update, Delete) 기능을 구현하는 방법을 설명합니다. 이를 위해 ASP.NET Core MVC의 Scaffolding 기능을 사용하여, 관리자가 IP 범위를 쉽게 관리할 수 있는 인터페이스를 생성합니다. 또한, 이러한 기능에 접근할 수 있는 것은 'Administrators' 역할에 포함된 사용자로 제한합니다.

1. Scaffolding을 이용한 컨트롤러 및 뷰 생성

  1. Scaffolding 추가:

    • Visual Studio를 사용하는 경우: 솔루션 탐색기에서 프로젝트를 우클릭하고 'Add' -> 'New Scaffolded Item...'을 선택합니다. 'MVC Controller with views, using Entity Framework'를 선택하고 'Add'를 클릭합니다.

    • .NET CLI를 사용하는 경우: 프로젝트 디렉터리에서 다음 명령을 실행합니다:

      dotnet aspnet-codegenerator controller -name AllowedIPRangesController -m AllowedIPRange -dc ApplicationDbContext --relativeFolderPath Controllers --useDefaultLayout --referenceScriptLibraries
      
  2. 모델 및 데이터 컨텍스트 클래스 선택:

    • 'Model class'에서 AllowedIPRange를 선택하고, 'Data context class'에서 ApplicationDbContext를 선택합니다.
  3. Scaffolding 실행:

    • 모든 설정을 마친 후 'Add'를 클릭하여 Scaffolding을 실행합니다. 이 과정에서 Controllers 폴더에 AllowedIPRangesController가 생성되고, 관련 뷰 파일이 Views/AllowedIPRanges 폴더에 생성됩니다.

2. CRUD 기능의 보안 강화

  • 'Administrators' 역할 접근 제한: AllowedIPRangesController의 모든 액션 메서드 또는 특정 메서드에 대해 [Authorize(Roles = "Administrators")] 속성을 추가하여, 해당 역할에 속한 사용자만 접근할 수 있도록 합니다.

    [Authorize(Roles = "Administrators")]
    public class AllowedIPRangesController : Controller
    {
        // ...
    }
    
  • 예외 처리 및 로깅: 각 액션 메서드에 예외 처리 로직을 추가하여, 발생 가능한 오류를 적절히 처리하고 로깅합니다.

3. 마무리 및 테스트

  • 마이그레이션 및 데이터베이스 업데이트: 필요한 경우 추가 마이그레이션을 생성하고 데이터베이스를 업데이트합니다.

  • 애플리케이션 테스트: 애플리케이션을 실행하고, 'Administrators' 역할에 속한 사용자로 로그인하여 AllowedIPRanges 테이블에 대한 CRUD 기능이 제대로 작동하는지 확인합니다.

이 단계를 통해 관리자는 웹 인터페이스를 통해 IP 주소 제한을 쉽게 관리할 수 있게 되며, 애플리케이션의 보안 및 유지 관리가 간편해집니다.

AllowedIPRangesController.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using VisualAcademy.Data;
using VisualAcademy.Models;

namespace VisualAcademy.Controllers
{
    [Authorize(Roles = "Administrators")]
    public class AllowedIPRangesController : Controller
    {
        private readonly ApplicationDbContext _context;

        public AllowedIPRangesController(ApplicationDbContext context)
        {
            _context = context;
        }

        // GET: AllowedIPRanges
        public async Task<IActionResult> Index()
        {
            return View(await _context.AllowedIPRanges.ToListAsync());
        }

        // GET: AllowedIPRanges/Details/5
        public async Task<IActionResult> Details(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var allowedIPRange = await _context.AllowedIPRanges
                .FirstOrDefaultAsync(m => m.Id == id);
            if (allowedIPRange == null)
            {
                return NotFound();
            }

            return View(allowedIPRange);
        }

        // GET: AllowedIPRanges/Create
        public IActionResult Create()
        {
            return View();
        }

        // POST: AllowedIPRanges/Create
        // To protect from overposting attacks, enable the specific properties you want to bind to.
        // For more details, see http://go.microsoft.com/fwlink/?LinkId=317598.
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Create([Bind("Id,StartIPRange,EndIPRange,Description,CreateDate,TenantId")] AllowedIPRange allowedIPRange)
        {
            if (ModelState.IsValid)
            {
                _context.Add(allowedIPRange);
                await _context.SaveChangesAsync();
                return RedirectToAction(nameof(Index));
            }
            return View(allowedIPRange);
        }

        // GET: AllowedIPRanges/Edit/5
        public async Task<IActionResult> Edit(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var allowedIPRange = await _context.AllowedIPRanges.FindAsync(id);
            if (allowedIPRange == null)
            {
                return NotFound();
            }
            return View(allowedIPRange);
        }

        // POST: AllowedIPRanges/Edit/5
        // To protect from overposting attacks, enable the specific properties you want to bind to.
        // For more details, see http://go.microsoft.com/fwlink/?LinkId=317598.
        [HttpPost]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Edit(int id, [Bind("Id,StartIPRange,EndIPRange,Description,CreateDate,TenantId")] AllowedIPRange allowedIPRange)
        {
            if (id != allowedIPRange.Id)
            {
                return NotFound();
            }

            if (ModelState.IsValid)
            {
                try
                {
                    _context.Update(allowedIPRange);
                    await _context.SaveChangesAsync();
                }
                catch (DbUpdateConcurrencyException)
                {
                    if (!AllowedIPRangeExists(allowedIPRange.Id))
                    {
                        return NotFound();
                    }
                    else
                    {
                        throw;
                    }
                }
                return RedirectToAction(nameof(Index));
            }
            return View(allowedIPRange);
        }

        // GET: AllowedIPRanges/Delete/5
        public async Task<IActionResult> Delete(int? id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var allowedIPRange = await _context.AllowedIPRanges
                .FirstOrDefaultAsync(m => m.Id == id);
            if (allowedIPRange == null)
            {
                return NotFound();
            }

            return View(allowedIPRange);
        }

        // POST: AllowedIPRanges/Delete/5
        [HttpPost, ActionName("Delete")]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> DeleteConfirmed(int id)
        {
            var allowedIPRange = await _context.AllowedIPRanges.FindAsync(id);
            if (allowedIPRange != null)
            {
                _context.AllowedIPRanges.Remove(allowedIPRange);
            }

            await _context.SaveChangesAsync();
            return RedirectToAction(nameof(Index));
        }

        private bool AllowedIPRangeExists(int id)
        {
            return _context.AllowedIPRanges.Any(e => e.Id == id);
        }
    }
}

VisualAcademy\Views\AllowedIPRanges\Index.cshtml

@model IEnumerable<VisualAcademy.Models.AllowedIPRange>

@{
    ViewData["Title"] = "Index";
}

<h1>Index</h1>

<p>
    <a asp-action="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.StartIPRange)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.EndIPRange)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Description)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.CreateDate)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.TenantId)
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
@foreach (var item in Model) {
        <tr>
            <td>
                @Html.DisplayFor(modelItem => item.StartIPRange)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.EndIPRange)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.Description)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.CreateDate)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.TenantId)
            </td>
            <td>
                <a asp-action="Edit" asp-route-id="@item.Id">Edit</a> |
                <a asp-action="Details" asp-route-id="@item.Id">Details</a> |
                <a asp-action="Delete" asp-route-id="@item.Id">Delete</a>
            </td>
        </tr>
}
    </tbody>
</table>

사용자 테넌트에 따른 CRUD 기능 구현

https://youtu.be/6iacssq3Vcw

아래는 TenantAllowedIPRangesController라는 새로운 컨트롤러입니다. 이 컨트롤러는 현재 로그인한 사용자가 속한 테넌트의 AllowedIPRanges에 대한 CRUD 작업을 수행할 수 있도록 설계되었습니다. AllowedIPRangesController는 관리자가 사용하는 것으로 유지하고, TenantAllowedIPRangesController는 일반 테넌트 사용자가 사용합니다.

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;
using VisualAcademy.Data;
using VisualAcademy.Models;

[Authorize] // 로그인된 사용자만 접근 가능
public class TenantAllowedIPRangesController : Controller
{
    private readonly ApplicationDbContext _context;
    private readonly UserManager<ApplicationUser> _userManager;

    public TenantAllowedIPRangesController(ApplicationDbContext context, UserManager<ApplicationUser> userManager)
    {
        _context = context;
        _userManager = userManager;
    }

    // 현재 사용자의 TenantId를 가져오는 메서드
    private async Task<long?> GetCurrentTenantId()
    {
        var user = await _userManager.GetUserAsync(User);
        return user?.TenantId; // null 반환 가능성을 명시
    }

    // Index: 현재 사용자의 TenantId에 해당하는 AllowedIPRanges 목록을 표시
    public async Task<IActionResult> Index()
    {
        var tenantId = await GetCurrentTenantId();
        if (tenantId == null)
        {
            return NotFound("Tenant ID not found for the current user.");
        }
        var allowedIPRanges = _context.AllowedIPRanges.Where(a => a.TenantId == tenantId);
        return View(await allowedIPRanges.ToListAsync());
    }

    // Details: 특정 AllowedIPRange의 세부 정보를 표시
    public async Task<IActionResult> Details(int? id)
    {
        if (id == null)
        {
            return NotFound();
        }

        var tenantId = await GetCurrentTenantId();
        if (tenantId == null)
        {
            return NotFound("Tenant ID not found for the current user.");
        }

        var allowedIPRange = await _context.AllowedIPRanges
            .FirstOrDefaultAsync(m => m.Id == id && m.TenantId == tenantId);

        if (allowedIPRange == null)
        {
            return NotFound();
        }

        return View(allowedIPRange);
    }

    // Create: 새 AllowedIPRange를 생성
    public IActionResult Create()
    {
        return View();
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Create([Bind("StartIPRange,EndIPRange,Description")] AllowedIPRange allowedIPRange)
    {
        var tenantId = await GetCurrentTenantId();
        if (tenantId == null)
        {
            return NotFound("Tenant ID not found for the current user.");
        }

        if (ModelState.IsValid)
        {
            allowedIPRange.TenantId = tenantId.Value;
            _context.Add(allowedIPRange);
            await _context.SaveChangesAsync();
            return RedirectToAction(nameof(Index));
        }
        return View(allowedIPRange);
    }

    // Edit: 기존 AllowedIPRange를 수정
    public async Task<IActionResult> Edit(int? id)
    {
        if (id == null)
        {
            return NotFound();
        }

        var tenantId = await GetCurrentTenantId();
        if (tenantId == null)
        {
            return NotFound("Tenant ID not found for the current user.");
        }

        var allowedIPRange = await _context.AllowedIPRanges
            .FirstOrDefaultAsync(m => m.Id == id && m.TenantId == tenantId);

        if (allowedIPRange == null)
        {
            return NotFound();
        }

        return View(allowedIPRange);
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Edit(int id, [Bind("Id,StartIPRange,EndIPRange,Description")] AllowedIPRange allowedIPRange)
    {
        if (id != allowedIPRange.Id)
        {
            return NotFound();
        }

        var tenantId = await GetCurrentTenantId();
        if (tenantId == null)
        {
            return NotFound("Tenant ID not found for the current user.");
        }

        if (ModelState.IsValid)
        {
            try
            {
                if (allowedIPRange.TenantId != tenantId)
                {
                    return NotFound();
                }

                _context.Update(allowedIPRange);
                await _context.SaveChangesAsync();
            }
            catch (DbUpdateConcurrencyException)
            {
                if (!AllowedIPRangeExists(allowedIPRange.Id))
                {
                    return NotFound();
                }
                else
                {
                    throw;
                }
            }
            return RedirectToAction(nameof(Index));
        }
        return View(allowedIPRange);
    }

    // Delete: 특정 AllowedIPRange를 삭제
    public async Task<IActionResult> Delete(int? id)
    {
        if (id == null)
        {
            return NotFound();
        }

        var tenantId = await GetCurrentTenantId();
        if (tenantId == null)
        {
            return NotFound("Tenant ID not found for the current user.");
        }

        var allowedIPRange = await _context.AllowedIPRanges
            .FirstOrDefaultAsync(m => m.Id == id && m.TenantId == tenantId);

        if (allowedIPRange == null)
        {
            return NotFound();
        }

        return View(allowedIPRange);
    }

    // 실제로 삭제하는 POST 메서드
    [HttpPost, ActionName("Delete")]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> DeleteConfirmed(int id)
    {
        var tenantId = await GetCurrentTenantId();
        if (tenantId == null)
        {
            return NotFound("Tenant ID not found for the current user.");
        }

        var allowedIPRange = await _context.AllowedIPRanges
            .FirstOrDefaultAsync(m => m.Id == id && m.TenantId == tenantId);

        if (allowedIPRange != null)
        {
            _context.AllowedIPRanges.Remove(allowedIPRange);
            await _context.SaveChangesAsync();
        }
        return RedirectToAction(nameof(Index));
    }

    private bool AllowedIPRangeExists(int id)
    {
        var tenantId = _userManager.GetUserAsync(User).Result?.TenantId; // 동기적 방식으로 현재 사용자의 TenantId를 가져옴
        return _context.AllowedIPRanges.Any(e => e.Id == id && e.TenantId == tenantId);
    }
}

이 컨트롤러는 ApplicationUserUserManager를 사용하여 현재 로그인한 사용자의 TenantId를 확인하고, 해당 TenantId에 속한 AllowedIPRanges에 대해서만 작업을 수행합니다. 각 CRUD 작업은 사용자가 속한 테넌트의 데이터에만 영향을 미치도록 보장합니다.

뷰 파일(Index, Create, Details, Edit, Delete)은 컨트롤러와 유사한 로직을 사용하여 데이터를 표시하고 사용자 입력을 처리합니다. 필요에 따라 뷰 파일을 추가하거나 수정하여 사용자에게 적절한 정보와 인터페이스를 제공할 수 있습니다.

마지막으로, 이 컨트롤러와 뷰를 사용하기 전에 필요한 경우 데이터베이스 마이그레이션을 생성하고 적용해야 합니다. 그리고 애플리케이션을 실행하여 테스트를 수행하고, 모든 것이 예상대로 작동하는지 확인합니다.

VisualAcademy\Views\TenantAllowedIPRanges\Index.cshtml

@model IEnumerable<VisualAcademy.Models.AllowedIPRange>

@{
    ViewData["Title"] = "Index";
}

<h1>Index</h1>

<p>
    <a asp-action="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.StartIPRange)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.EndIPRange)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Description)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.CreateDate)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.TenantId)
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
@foreach (var item in Model) {
        <tr>
            <td>
                @Html.DisplayFor(modelItem => item.StartIPRange)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.EndIPRange)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.Description)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.CreateDate)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.TenantId)
            </td>
            <td>
                <a asp-action="Edit" asp-route-id="@item.Id">Edit</a> |
                <a asp-action="Details" asp-route-id="@item.Id">Details</a> |
                <a asp-action="Delete" asp-route-id="@item.Id">Delete</a>
            </td>
        </tr>
}
    </tbody>
</table>

구성 기반 IP 제한

이 섹션에서는 appsettings.json 파일을 통해 IP 제한 기능을 구성하고, 로그인 시 IP 정보를 수집하는 방법을 소개합니다. 이를 통해 애플리케이션의 IP 제한 기능을 유연하게 활성화하거나 비활성화하며, 필요한 경우 로그인 시 IP 정보를 수집할 수 있습니다.

https://youtu.be/mwxFPJLurH4

appsettings.json 설정 추가

appsettings.json 파일에 다음과 같은 설정을 추가합니다. 이 설정은 IP 제한 기능의 활성화 여부와 로그인 시 IP 수집 여부를 결정합니다.

{
  // ... 기존 설정 ...

  "IPRestriction": {
    "EnableIPRestriction": true, // IP 제한 기능 활성화
    "CollectLoginIP": true       // 로그인 시 IP 수집 활성화
  }
}

로그인 페이지의 IP 수집 및 제한 로직

로그인 페이지의 코드 비하인드 파일 (Login.cshtml.cs)을 수정하여 appsettings.json의 구성 옵션에 따라 IP 수집 및 IP 제한 기능을 동시에 적용합니다. 이를 통해 로그인 시도 시 사용자의 IP 주소를 검사하고, 필요에 따라 특정 IP 주소 범위에 대한 접근을 제한할 수 있습니다. 또한, 설정에 따라 로그인한 사용자의 IP를 수집하여 추가적인 보안 및 모니터링 목적으로 사용할 수 있습니다. 이 모든 과정은 appsettings.json에서 설정을 조정하여 쉽게 활성화하거나 비활성화할 수 있으며, 애플리케이션의 보안 요구 사항에 따라 유연하게 구성할 수 있습니다.

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
using VisualAcademy.Data;
using Microsoft.EntityFrameworkCore;
using VisualAcademy.Models;
using System.Net;

namespace VisualAcademy.Areas.Identity.Pages.Account
{
    public class LoginModel : PageModel
    {
        private readonly SignInManager<ApplicationUser> _signInManager;
        private readonly UserManager<ApplicationUser> _userManager;
        private readonly ILogger<LoginModel> _logger;
        private readonly ApplicationDbContext _context;
        private readonly IConfiguration _configuration;

        public LoginModel(SignInManager<ApplicationUser> signInManager, UserManager<ApplicationUser> userManager, ILogger<LoginModel> logger, ApplicationDbContext context, IConfiguration configuration)
        {
            _signInManager = signInManager;
            _userManager = userManager;
            _logger = logger;
            _context = context;
            _configuration = configuration;
        }

        /// <summary>
        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
        ///     directly from your code. This API may change or be removed in future releases.
        /// </summary>
        [BindProperty]
        public InputModel Input { get; set; }

        /// <summary>
        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
        ///     directly from your code. This API may change or be removed in future releases.
        /// </summary>
        public IList<AuthenticationScheme> ExternalLogins { get; set; }

        /// <summary>
        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
        ///     directly from your code. This API may change or be removed in future releases.
        /// </summary>
        public string ReturnUrl { get; set; }

        /// <summary>
        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
        ///     directly from your code. This API may change or be removed in future releases.
        /// </summary>
        [TempData]
        public string ErrorMessage { get; set; }

        /// <summary>
        ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
        ///     directly from your code. This API may change or be removed in future releases.
        /// </summary>
        public class InputModel
        {
            /// <summary>
            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
            ///     directly from your code. This API may change or be removed in future releases.
            /// </summary>
            [Required]
            [EmailAddress]
            public string Email { get; set; }

            /// <summary>
            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
            ///     directly from your code. This API may change or be removed in future releases.
            /// </summary>
            [Required]
            [DataType(DataType.Password)]
            public string Password { get; set; }

            /// <summary>
            ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
            ///     directly from your code. This API may change or be removed in future releases.
            /// </summary>
            [Display(Name = "Remember me?")]
            public bool RememberMe { get; set; }
        }

        public async Task OnGetAsync(string returnUrl = null)
        {
            if (!string.IsNullOrEmpty(ErrorMessage))
            {
                ModelState.AddModelError(string.Empty, ErrorMessage);
            }

            returnUrl ??= Url.Content("~/");

            // Clear the existing external cookie to ensure a clean login process
            await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);

            ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();

            ReturnUrl = returnUrl;
        }

        public async Task<IActionResult> OnPostAsync(string returnUrl = null)
        {
            returnUrl ??= Url.Content("~/");

            ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();

            if (ModelState.IsValid)
            {
                // 구성에서 IP 수집 및 제한 설정을 읽어옵니다.
                bool enableIPRestriction = _configuration.GetValue<bool>("IPRestriction:EnableIPRestriction");
                bool collectLoginIP = _configuration.GetValue<bool>("IPRestriction:CollectLoginIP");

                // This doesn't count login failures towards account lockout
                // To enable password failures to trigger account lockout, set lockoutOnFailure: true
                var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false);
                if (result.Succeeded)
                {
                    // 사용자 정보 검색
                    var user = await _userManager.FindByEmailAsync(Input.Email);
                    var TenantId = user.TenantId;

                    // 현재 IP 주소 검색
                    string currentIP = HttpContext.Connection.RemoteIpAddress.ToString();
                    // 로컬호스트 IPv6 주소인 '::1'을 '127.0.0.1'로 변환
                    if (currentIP == "::1")
                    {
                        currentIP = "127.0.0.1";
                    }

                    // IP 수집이 활성화된 경우, 현재 IP를 수집합니다.
                    if (collectLoginIP)
                    {
                        try
                        {
                            var ipParts = currentIP.Split('.');
                            if (ipParts.Length == 4) // IPv4 주소인지 확인
                            {
                                // 마지막 옥텟을 1로 설정하여 StartIPRange 계산
                                ipParts[3] = "1";
                                string startIPRange = string.Join(".", ipParts);

                                // 마지막 옥텟을 255로 설정하여 EndIPRange 계산
                                ipParts[3] = "255";
                                string endIPRange = string.Join(".", ipParts);

                                // 사용자 이메일에서 도메인 부분만 추출
                                string emailDomain = Input.Email.Substring(Input.Email.IndexOf('@') + 1);

                                // 동일한 StartIPRange와 EndIPRange를 가진 엔트리가 이미 있는지 확인
                                var existingIPRange = await _context.AllowedIPRanges
                                    .FirstOrDefaultAsync(ip => ip.StartIPRange == startIPRange && ip.EndIPRange == endIPRange && ip.TenantId == user.TenantId);

                                // 동일한 범위가 존재하지 않는 경우에만 새 범위 추가
                                if (existingIPRange == null)
                                {
                                    var newIPRange = new AllowedIPRange
                                    {
                                        StartIPRange = startIPRange,
                                        EndIPRange = endIPRange,
                                        Description = emailDomain, // 사용자 이메일 도메인으로 설명 설정
                                        CreateDate = DateTime.Now,
                                        TenantId = user.TenantId // 현재 로그인한 사용자의 TenantId 사용
                                    };
                                    _context.AllowedIPRanges.Add(newIPRange);
                                    await _context.SaveChangesAsync();
                                }
                            }
                        }
                        catch (Exception ex)
                        {
                            // 예외 로깅 또는 사용자에게 친절한 에러 메시지 표시
                            // 예: _logger.LogError("An error occurred: {0}", ex.ToString());
                            // 사용자에게는 특정 에러 메시지를 반환하거나, 에러 페이지로 리디렉션할 수 있습니다.
                        }
                    }

                    // IP 제한이 활성화된 경우, 현재 IP 주소를 검사합니다.
                    if (enableIPRestriction)
                    { 
                        // IP 주소 허용 검사
                        bool isAllowed = await CheckIPAllowed(TenantId, currentIP);

                        if (!isAllowed)
                        {
                            // 허용되지 않은 IP 주소인 경우, RestrictedAccess 뷰로 리디렉션
                            return RedirectToPage("/RestrictedAccess");
                        }
                    }

                    _logger.LogInformation("User logged in.");
                    return LocalRedirect(returnUrl ?? Url.Content("~/"));
                }
                if (result.RequiresTwoFactor)
                {
                    return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe });
                }
                if (result.IsLockedOut)
                {
                    _logger.LogWarning("User account locked out.");
                    return RedirectToPage("./Lockout");
                }
                else
                {
                    ModelState.AddModelError(string.Empty, "Invalid login attempt.");
                    return Page();
                }
            }

            // If we got this far, something failed, redisplay form
            return Page();
        }

        // IP 허용 검사 메서드 구현
        private async Task<bool> CheckIPAllowed(long TenantId, string currentIP)
        {
            var ipRangeList = await _context.AllowedIPRanges
                                            .Where(r => r.TenantId == TenantId)
                                            .ToListAsync();

            // 특정 테넌트ID에 해당하는 등록된 허용IP주소가 없으면 제한을 걸지 않고 허용
            if (!ipRangeList.Any())
            {
                return true; // 등록된 허용된 IP 주소가 없으면 모든 접속을 허용
            }

            foreach (var range in ipRangeList)
            {
                if (IsIPInRange(currentIP, range.StartIPRange, range.EndIPRange))
                {
                    return true; // 현재 IP가 허용된 범위 내에 있으면 true 반환
                }
            }

            return false; // 허용된 범위에 없으면 false 반환
        }

        // IP 범위 확인 메서드
        private bool IsIPInRange(string currentIP, string startIP, string endIP)
        {
            var addr = IPAddress.Parse(currentIP);
            var lowerBound = IPAddress.Parse(startIP);
            var upperBound = IPAddress.Parse(endIP);

            byte[] addrBytes = addr.GetAddressBytes();
            byte[] lowerBytes = lowerBound.GetAddressBytes();
            byte[] upperBytes = upperBound.GetAddressBytes();

            bool lowerBoundCheck = true;
            bool upperBoundCheck = true;

            for (int i = 0; i < addrBytes.Length && (lowerBoundCheck || upperBoundCheck); i++)
            {
                if (lowerBoundCheck)
                {
                    if (addrBytes[i] < lowerBytes[i])
                    {
                        return false;
                    }
                    else if (addrBytes[i] > lowerBytes[i])
                    {
                        lowerBoundCheck = false;
                    }
                }

                if (upperBoundCheck)
                {
                    if (addrBytes[i] > upperBytes[i])
                    {
                        return false;
                    }
                    else if (addrBytes[i] < upperBytes[i])
                    {
                        upperBoundCheck = false;
                    }
                }
            }
            return true;
        }
    }
}

여기까지 진행하면 로그인 IP 수집 및 로그인 IP 제한의 기능이 적용된 로그인 페이지를 운영할 수 있습니다.












































VisualAcademy Docs의 모든 콘텐츠, 이미지, 동영상의 저작권은 박용준에게 있습니다. 저작권법에 의해 보호를 받는 저작물이므로 무단 전재와 복제를 금합니다. 사이트의 콘텐츠를 복제하여 블로그, 웹사이트 등에 게시할 수 없습니다. 단, 링크와 SNS 공유, Youtube 동영상 공유는 허용합니다. www.VisualAcademy.com
박용준 강사의 모든 동영상 강의는 데브렉에서 독점으로 제공됩니다. www.devlec.com