서버 쪽 ASP.NET Core Blazor 추가 보안 시나리오

참고 항목

이 문서의 최신 버전은 아닙니다. 현재 릴리스는 이 문서의 .NET 8 버전을 참조 하세요.

Important

이 정보는 상업적으로 출시되기 전에 실질적으로 수정될 수 있는 시험판 제품과 관련이 있습니다. Microsoft는 여기에 제공된 정보에 대해 어떠한 명시적, 또는 묵시적인 보증을 하지 않습니다.

현재 릴리스는 이 문서의 .NET 8 버전을 참조 하세요.

이 문서에서는 앱에 토큰을 전달하는 방법을 포함하여 추가 보안 시나리오에 대해 서버 쪽 Blazor 을 구성하는 방법을 설명합니다 Blazor .

참고 항목

이 문서의 코드 예제에서는 NRT(nullable 참조 형식) 및 .NET 컴파일러 null 상태 정적 분석을 채택합니다. 이 분석은 .NET 6 이상의 ASP.NET Core에서 지원됩니다. ASP.NET Core 5.0 이하를 대상으로 하는 경우 아티클 예제의 , TodoItem[]?WeatherForecast[]?IEnumerable<GitHubBranch>? 형식에서 string?null 형식 지정(?)을 제거합니다.

서버 쪽 Blazor 앱에 토큰 전달

Web Apps에 대한 Blazor 이 섹션을 업데이트하는 작업은 Web Apps(dotnet/AspNetCore.Docs#31691)에서 Blazor 토큰 전달에 대한 업데이트 섹션을 보류 중입니다. 자세한 내용은 대화형 서버 모드에서 HttpClient에 액세스 토큰을 제공하는 문제(dotnet/aspnetcore#52390)를 참조하세요.

의 경우 Blazor Server이 문서 섹션의 7.0 버전을 확인합니다.

서버 쪽 Blazor 앱의 Razor 구성 요소 외부에서 사용할 수 있는 토큰은 이 섹션에 설명된 방법을 사용하여 구성 요소에 전달할 수 있습니다. 이 섹션의 예제에서는 액세스, 새로 고침 및 XSRF(요청 방지 위조) 토큰 을 앱에 전달하는 데 Blazor 중점을 두지만 이 방법은 다른 HTTP 컨텍스트 상태에 유효합니다.

참고 항목

구성 요소에 XSRF 토큰을 Razor 전달하는 것은 구성 요소가 유효성 검사가 필요한 다른 엔드포인트에 게시하는 Identity 시나리오에서 유용합니다. 앱에 액세스 및 새로 고침 토큰만 필요한 경우 다음 예제에서 XSRF 토큰 코드를 제거할 수 있습니다.

일반 Razor Pages 또는 MVC 앱과 마찬가지로 앱을 인증합니다. 토큰을 프로비저닝하고 인증 cookie에 저장합니다.

Program 파일에서:

using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

...

builder.Services.Configure<OpenIdConnectOptions>(
    OpenIdConnectDefaults.AuthenticationScheme, options =>
{
    options.ResponseType = OpenIdConnectResponseType.Code;
    options.SaveTokens = true;
    options.Scope.Add(OpenIdConnectScope.OfflineAccess);
});

Startup.cs의 경우

using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

...

services.Configure<OpenIdConnectOptions>(
    OpenIdConnectDefaults.AuthenticationScheme, options =>
{
    options.ResponseType = OpenIdConnectResponseType.Code;
    options.SaveTokens = true;
    options.Scope.Add(OpenIdConnectScope.OfflineAccess);
});

Startup.cs의 경우

using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

...

services.Configure<OpenIdConnectOptions>(AzureADDefaults.OpenIdScheme, options =>
{
    options.ResponseType = OpenIdConnectResponseType.Code;
    options.SaveTokens = true;
    options.Scope.Add(OpenIdConnectScope.OfflineAccess);
});

필요에 따라 options.Scope.Add("{SCOPE}");를 사용하여 추가 범위가 추가됩니다. 여기서 자리 표시자 {SCOPE}는 추가할 추가 범위입니다.

DI(종속성 주입)에서 토큰을 확인하기 위해 앱 내에서 Blazor 사용할 수 있는 범위가 지정된 토큰 공급자 서비스를 정의합니다.

TokenProvider.cs:

public class TokenProvider
{
    public string? AccessToken { get; set; }
    public string? RefreshToken { get; set; }
    public string? XsrfToken { get; set; }
}

파일에서 Program 다음을 위한 서비스를 추가합니다.

  • IHttpClientFactory: 액세스 토큰을 사용하여 WeatherForecastService 서버 API에서 날씨 데이터를 가져오는 클래스에서 사용됩니다.
  • TokenProvider: 액세스 및 새로 고침 토큰을 보유합니다.
builder.Services.AddHttpClient();
builder.Services.AddScoped<TokenProvider>();

에서 Startup.ConfigureServicesStartup.cs다음을 위한 서비스를 추가합니다.

  • IHttpClientFactory: 액세스 토큰을 사용하여 WeatherForecastService 서버 API에서 날씨 데이터를 가져오는 클래스에서 사용됩니다.
  • TokenProvider: 액세스 및 새로 고침 토큰을 보유합니다.
services.AddHttpClient();
services.AddScoped<TokenProvider>();

액세스 및 새로 고침 토큰을 사용하여 초기 앱 상태를 전달할 클래스를 정의합니다.

InitialApplicationState.cs:

public class InitialApplicationState
{
    public string? AccessToken { get; set; }
    public string? RefreshToken { get; set; }
    public string? XsrfToken { get; set; }
}

Pages/_Host.cshtml 파일에서 InitialApplicationState의 인스턴스를 만들어 앱에 매개 변수로 전달합니다.

Pages/_Layout.cshtml 파일에서 InitialApplicationState의 인스턴스를 만들어 앱에 매개 변수로 전달합니다.

Pages/_Host.cshtml 파일에서 InitialApplicationState의 인스턴스를 만들어 앱에 매개 변수로 전달합니다.

@using Microsoft.AspNetCore.Authentication
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Xsrf

...

@{
    var tokens = new InitialApplicationState
    {
        AccessToken = await HttpContext.GetTokenAsync("access_token"),
        RefreshToken = await HttpContext.GetTokenAsync("refresh_token"),
        XsrfToken = Xsrf.GetAndStoreTokens(HttpContext).RequestToken
    };
}

<component ... param-InitialState="tokens" ... />

App 구성 요소(App.razor)에서 서비스를 확인하고 매개 변수로 받은 데이터를 사용하여 초기화합니다.

@inject TokenProvider TokenProvider

...

@code {
    [Parameter]
    public InitialApplicationState? InitialState { get; set; }

    protected override Task OnInitializedAsync()
    {
        TokenProvider.AccessToken = InitialState?.AccessToken;
        TokenProvider.RefreshToken = InitialState?.RefreshToken;
        TokenProvider.XsrfToken = InitialState?.XsrfToken;

        return base.OnInitializedAsync();
    }
}

참고 항목

앞의 예제에서 초기 상태를 TokenProvider 할당하는 대안은 앱 전체에서 사용하기 위해 범위가 지정된 서비스로 OnInitializedAsync 데이터를 복사하는 것입니다.

Microsoft.AspNet.WebApi.Client NuGet 패키지용 앱에 패키지 참조를 추가합니다.

참고 항목

.NET 앱에 패키지를 추가하는 방법에 대한 지침은 패키지 사용 워크플로에서 패키지 설치 및 관리의 문서(NuGet 설명서)를 참조하세요. NuGet.org에서 올바른 패키지 버전을 확인합니다.

보안 API 요청을 만드는 서비스에서 토큰 공급자를 삽입하고 API 요청의 토큰을 검색합니다.

WeatherForecastService.cs:

using System;
using System.Net.Http;
using System.Threading.Tasks;

public class WeatherForecastService
{
    private readonly HttpClient http;
    private readonly TokenProvider tokenProvider;

    public WeatherForecastService(IHttpClientFactory clientFactory, 
        TokenProvider tokenProvider)
    {
        http = clientFactory.CreateClient();
        this.tokenProvider = tokenProvider;
    }

    public async Task<WeatherForecast[]> GetForecastAsync()
    {
        var token = tokenProvider.AccessToken;
        var request = new HttpRequestMessage(HttpMethod.Get, 
            "https://localhost:5003/WeatherForecast");
        request.Headers.Add("Authorization", $"Bearer {token}");
        var response = await http.SendAsync(request);
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<WeatherForecast[]>() ?? 
            Array.Empty<WeatherForecast>();
    }
}

구성 요소에 전달된 XSRF 토큰의 TokenProvider 경우 XSRF 토큰을 삽입하고 POST 요청에 추가합니다. 다음 예제에서는 로그아웃 엔드포인트 POST에 토큰을 추가합니다. 다음 예제의 시나리오는 보호해야 하는 일반 로그아웃 작업 외에도 일부 작업을 수행하므로 로그아웃 엔드포인트(Areas/Identity/Pages/Account/Logout.cshtml으로 스캐폴드됨)가 (@attribute [IgnoreAntiforgeryToken])를 지정 IgnoreAntiforgeryTokenAttribute 하지 않는 것입니다. 요청을 성공적으로 처리하려면 엔드포인트에 유효한 XSRF 토큰이 필요합니다.

권한 있는 사용자에게 로그아웃 단추를 제공하는 구성 요소에서 다음을 수행합니다.

@inject TokenProvider TokenProvider

...

<AuthorizeView>
    <Authorized>
        <form action="/Identity/Account/Logout?returnUrl=%2F" method="post">
            <button class="nav-link btn btn-link" type="submit">Logout</button>
            <input name="__RequestVerificationToken" type="hidden" 
                value="@TokenProvider.XsrfToken">
        </form>
    </Authorized>
    <NotAuthorized>
        ...
    </NotAuthorized>
</AuthorizeView>

인증 체계 설정

둘 이상의 인증 미들웨어를 사용하므로 둘 이상의 인증 체계를 사용하는 앱의 경우 파일의 Program 엔드포인트 구성에서 사용하는 체계 Blazor 를 명시적으로 설정할 수 있습니다. 다음 예에서는 OIDC(OpenID Connect) 체계를 설정합니다.

여러 인증 미들웨어를 사용하여 인증 체계가 두 개 이상인 앱의 경우 Blazor가 사용하는 체계를 Startup.cs의 엔드포인트 구성에서 명시적으로 설정할 수 있습니다. 다음 예에서는 OIDC(OpenID Connect) 체계를 설정합니다.

using Microsoft.AspNetCore.Authentication.OpenIdConnect;

...

app.MapRazorComponents<App>().RequireAuthorization(
    new AuthorizeAttribute
    {
        AuthenticationSchemes = OpenIdConnectDefaults.AuthenticationScheme
    })
    .AddInteractiveServerRenderMode();
using Microsoft.AspNetCore.Authentication.OpenIdConnect;

...

app.MapBlazorHub().RequireAuthorization(
    new AuthorizeAttribute 
    {
        AuthenticationSchemes = OpenIdConnectDefaults.AuthenticationScheme
    });

여러 인증 미들웨어를 사용하여 인증 체계가 두 개 이상인 앱의 경우 Blazor가 사용하는 체계를 Startup.Configure의 엔드포인트 구성에서 명시적으로 설정할 수 있습니다. 다음 예제에서는 Microsoft Entra ID 체계를 설정합니다.

endpoints.MapBlazorHub().RequireAuthorization(
    new AuthorizeAttribute 
    {
        AuthenticationSchemes = AzureADDefaults.AuthenticationScheme
    });

OIDC(OpenID Connect) v2.0 엔드포인트 사용

5.0 이전 버전의 ASP.NET Core에서 인증 라이브러리 및 Blazor 템플릿은 OIDC(OpenID Connect) v1.0 엔드포인트를 사용합니다. 5.0 이전 버전의 ASP.NET Core에서 v2.0 엔드포인트를 사용하려면 OpenIdConnectOptions에서 OpenIdConnectOptions.Authority 옵션을 구성합니다.

services.Configure<OpenIdConnectOptions>(AzureADDefaults.OpenIdScheme, 
    options =>
    {
        options.Authority += "/v2.0";
    }

또는 앱 설정(appsettings.json) 파일에서 설정을 수행할 수 있습니다.

{
  "AzureAd": {
    "Authority": "https://login.microsoftonline.com/common/oauth2/v2.0/",
    ...
  }
}

ME ID가 아닌 공급자와 같이 앱의 OIDC 공급자에 대해 기관에 세그먼트를 태킹하는 것이 적절하지 않은 경우 속성을 직접 설정합니다 Authority . OpenIdConnectOptions 또는 앱 설정 파일에서 Authority 키를 사용하여 속성을 설정합니다.

코드 변경 내용

  • ID 토큰의 클레임 목록은 v2.0 엔드포인트의 경우 변경됩니다. 변경 내용에 대한 Microsoft 설명서는 사용 중지되었지만 ID 토큰의 클레임에 대한 지침은 ID 토큰 클레임 참조에서 사용할 수 있습니다.

  • v2.0 엔드포인트의 경우 범위 URI에 리소스가 지정되어 있으므로 OpenIdConnectOptions에서 OpenIdConnectOptions.Resource 속성 설정을 제거합니다.

    services.Configure<OpenIdConnectOptions>(AzureADDefaults.OpenIdScheme, options => 
        {
            ...
            options.Resource = "...";    // REMOVE THIS LINE
            ...
        }
    

앱 ID URI

  • v2.0 엔드포인트를 사용할 경우 API는 API의 고유 식별자를 나타내는 App ID URI를 정의합니다.
  • 모든 범위에는 앱 ID URI가 접두어로 포함되며 v2.0 엔드포인트는 앱 ID URI를 대상으로 하는 액세스 토큰을 내보냅니다.
  • V2.0 엔드포인트를 사용할 경우 서버 API에 구성된 클라이언트 ID가 API 애플리케이션 ID(클라이언트 ID)에서 앱 ID URI로 변경됩니다.

appsettings.json:

{
  "AzureAd": {
    ...
    "ClientId": "https://{TENANT}.onmicrosoft.com/{PROJECT NAME}"
    ...
  }
}

OIDC 공급자 앱 등록 설명에서 사용해야 하는 앱 ID URI를 확인할 수 있습니다.

사용자 지정 서비스에 대한 사용자를 캡처하는 회로 처리기

A를 CircuitHandler 사용하여 서비스에서 사용자를 AuthenticationStateProvider 캡처하고 사용자를 설정합니다. 사용자를 업데이트하려면 콜백을 등록하고 큐에 AuthenticationStateChangedTask 대기하여 새 사용자를 가져오고 서비스를 업데이트합니다. 다음 예제에서는 이 접근 방식을 보여 줍니다.

다음 예제에서

  • OnConnectionUpAsync 는 회로가 다시 연결될 때마다 호출되며 연결 수명 동안 사용자를 설정합니다. 다음 예제에서는 OnConnectionUpAsync 인증 변경AuthenticationChanged 에 대한 처리기를 통해 업데이트를 구현하지 않는 한 메서드만 필요합니다.
  • OnCircuitOpenedAsync 는 인증 변경된 처리기를 AuthenticationChanged연결하고 사용자를 업데이트하기 위해 호출됩니다.
  • 태스크 블록 UpdateAuthenticationcatch 코드 실행의 이 시점에서 보고할 방법이 없기 때문에 예외에 대해 아무 작업도 수행하지 않습니다. 작업에서 예외가 throw되면 예외가 앱의 다른 위치에서 보고됩니다.

UserService.cs:

using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server.Circuits;

public class UserService
{
    private ClaimsPrincipal currentUser = new(new ClaimsIdentity());

    public ClaimsPrincipal GetUser()
    {
        return currentUser;
    }

    internal void SetUser(ClaimsPrincipal user)
    {
        if (currentUser != user)
        {
            currentUser = user;
        }
    }
}

internal sealed class UserCircuitHandler : CircuitHandler, IDisposable
{
    private readonly AuthenticationStateProvider authenticationStateProvider;
    private readonly UserService userService;

    public UserCircuitHandler(
        AuthenticationStateProvider authenticationStateProvider,
        UserService userService)
    {
        this.authenticationStateProvider = authenticationStateProvider;
        this.userService = userService;
    }

    public override Task OnCircuitOpenedAsync(Circuit circuit, 
        CancellationToken cancellationToken)
    {
        authenticationStateProvider.AuthenticationStateChanged += 
            AuthenticationChanged;

        return base.OnCircuitOpenedAsync(circuit, cancellationToken);
    }

    private void AuthenticationChanged(Task<AuthenticationState> task)
    {
        _ = UpdateAuthentication(task);

        async Task UpdateAuthentication(Task<AuthenticationState> task)
        {
            try
            {
                var state = await task;
                userService.SetUser(state.User);
            }
            catch
            {
            }
        }
    }

    public override async Task OnConnectionUpAsync(Circuit circuit, 
        CancellationToken cancellationToken)
    {
        var state = await authenticationStateProvider.GetAuthenticationStateAsync();
        userService.SetUser(state.User);
    }

    public void Dispose()
    {
        authenticationStateProvider.AuthenticationStateChanged -= 
            AuthenticationChanged;
    }
}
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server.Circuits;

public class UserService
{
    private ClaimsPrincipal currentUser = new ClaimsPrincipal(new ClaimsIdentity());

    public ClaimsPrincipal GetUser()
    {
        return currentUser;
    }

    internal void SetUser(ClaimsPrincipal user)
    {
        if (currentUser != user)
        {
            currentUser = user;
        }
    }
}

internal sealed class UserCircuitHandler : CircuitHandler, IDisposable
{
    private readonly AuthenticationStateProvider authenticationStateProvider;
    private readonly UserService userService;

    public UserCircuitHandler(
        AuthenticationStateProvider authenticationStateProvider,
        UserService userService)
    {
        this.authenticationStateProvider = authenticationStateProvider;
        this.userService = userService;
    }

    public override Task OnCircuitOpenedAsync(Circuit circuit, 
        CancellationToken cancellationToken)
    {
        authenticationStateProvider.AuthenticationStateChanged += 
            AuthenticationChanged;

        return base.OnCircuitOpenedAsync(circuit, cancellationToken);
    }

    private void AuthenticationChanged(Task<AuthenticationState> task)
    {
        _ = UpdateAuthentication(task);

        async Task UpdateAuthentication(Task<AuthenticationState> task)
        {
            try
            {
                var state = await task;
                userService.SetUser(state.User);
            }
            catch
            {
            }
        }
    }

    public override async Task OnConnectionUpAsync(Circuit circuit, 
        CancellationToken cancellationToken)
    {
        var state = await authenticationStateProvider.GetAuthenticationStateAsync();
        userService.SetUser(state.User);
    }

    public void Dispose()
    {
        authenticationStateProvider.AuthenticationStateChanged -= 
            AuthenticationChanged;
    }
}

Program 파일에서:

using Microsoft.AspNetCore.Components.Server.Circuits;
using Microsoft.Extensions.DependencyInjection.Extensions;

...

builder.Services.AddScoped<UserService>();
builder.Services.TryAddEnumerable(
    ServiceDescriptor.Scoped<CircuitHandler, UserCircuitHandler>());

Startup.csStartup.ConfigureServices에서 다음을 수행합니다.

using Microsoft.AspNetCore.Components.Server.Circuits;
using Microsoft.Extensions.DependencyInjection.Extensions;

...

services.AddScoped<UserService>();
services.TryAddEnumerable(
    ServiceDescriptor.Scoped<CircuitHandler, UserCircuitHandler>());

구성 요소의 서비스를 사용하여 사용자를 가져옵니다.

@inject UserService UserService

<h1>Hello, @(UserService.GetUser().Identity?.Name ?? "world")!</h1>

MVC, Razor Pages 및 기타 ASP.NET Core 시나리오의 미들웨어에서 사용자를 설정하려면 인증 미들웨어가 실행된 후 사용자 지정 미들웨어에서 호출 SetUserUserService 하거나 구현을 사용하여 IClaimsTransformation 사용자를 설정합니다. 다음 예제에서는 미들웨어 접근 방식을 채택합니다.

UserServiceMiddleware.cs:

public class UserServiceMiddleware
{
    private readonly RequestDelegate next;

    public UserServiceMiddleware(RequestDelegate next)
    {
        this.next = next ?? throw new ArgumentNullException(nameof(next));
    }

    public async Task InvokeAsync(HttpContext context, UserService service)
    {
        service.SetUser(context.User);
        await next(context);
    }
}

파일에서 Program 호출하기 app.MapRazorComponents<App>() 직전에 미들웨어를 호출합니다.

파일에서 Program 호출하기 app.MapBlazorHub() 직전에 미들웨어를 호출합니다.

in에 대한 호출 app.MapBlazorHub()Startup.ConfigureStartup.cs바로 전에 미들웨어를 호출합니다.

app.UseMiddleware<UserServiceMiddleware>();

나가는 요청 미들웨어의 액세스 AuthenticationStateProvider

AuthenticationStateProviderDelegatingHandler 회로 작업 처리기를 사용하여 나가는 요청 미들웨어에서 만든 IHttpClientFactory for HttpClient 에 액세스할 수 있습니다.

참고 항목

ASP.NET Core 앱에서 사용하여 IHttpClientFactory 만든 인스턴스에서 HttpClient HTTP 요청에 대한 위임 처리기를 정의하는 일반적인 지침은 ASP.NET Core에서 IHttpClientFactory를 사용하여 HTTP 요청 만들기의 다음 섹션을 참조하세요.

다음 예제에서는 인증된 사용자의 사용자 지정 사용자 이름 헤더를 나가는 요청에 연결하는 데 사용합니다 AuthenticationStateProvider .

먼저 DI(종속성 주입) 문서의 다음 섹션에서 Blazor 클래스를 구현 CircuitServicesAccessor 합니다.

다른 DI 범위에서 서버 쪽 Blazor 서비스에 액세스

구현에서 CircuitServicesAccessorDelegatingHandler 액세스 AuthenticationStateProvider 하려면 사용합니다.

AuthenticationStateHandler.cs:

public class AuthenticationStateHandler : DelegatingHandler
{
    readonly CircuitServicesAccessor circuitServicesAccessor;

    public AuthenticationStateHandler(
        CircuitServicesAccessor circuitServicesAccessor)
    {
        this.circuitServicesAccessor = circuitServicesAccessor;
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var authStateProvider = circuitServicesAccessor.Services
            .GetRequiredService<AuthenticationStateProvider>();
        var authState = await authStateProvider.GetAuthenticationStateAsync();
        var user = authState.User;

        if (user.Identity is not null && user.Identity.IsAuthenticated)
        {
            request.Headers.Add("X-USER-IDENTITY-NAME", user.Identity.Name);
        }

        return await base.SendAsync(request, cancellationToken);
    }
}

파일에서 인스턴스를 ProgramAuthenticationStateHandler 만드는 HttpClient 처리기를 IHttpClientFactory 등록하고 추가합니다.

builder.Services.AddTransient<AuthenticationStateHandler>();

builder.Services.AddHttpClient("HttpMessageHandler")
    .AddHttpMessageHandler<AuthenticationStateHandler>();