안녕하세요. asp.net blazor jwt [authorize] 질문드립니다.

안녕하세요. asp.net blazor server 질문 드립니다.

web api + blazor 작업중인데

blazor 페이지 내에서 [authorize] 특성을 사용하면 에러가 납니다.

솔루루션 전체 압축해서 링크 걸어봅니다.

https://www.1999.co.kr/sharing/vAYIhowPX

blazor 초보자 도움 좀 부탁드립니다.

1 Like

오… url이 탐나는데…

2 Likes

ㅎㅎ

1 Like

WebApi 프로젝트에서 예외가 발생하는건가요?

이렇게 한번해보세요.

1 Like

web api 에서는 이상없이 잘 작동하는데.

blazor server 의

razor 페이지 내에서

@attribute [Authorize] 속성을 추가하면 에러가 납니다.

보내주신건 web api 에서 설정했습니다.

1 Like

예외 메시지가 말해 주듯, 등록된 인증 체계(스킴)가 없어서 발생한 문제입니다.

이 원인은 인증 서비스 등록할 때, 기본 체계는 “Cookie” 라는 인증 체계를 사용하라고 설정했지만,
그 이름으로 등록된 인증 체계를 추가하지 않았기 때문입니다.

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
}).AddJwtBearer("Bearer");

위 코드에서, CookieAuthenticationDefaults.AuthenticationScheme 는 문자열 상수로 값은 "Cookie"입니다.

아래와 같이 쿠키 인증 체계를 등록하거나,

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
    .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddJwtBearer("Bearer");

쿠키 인증이 필요 없다면, 아래와 같이 토큰 인증 체계만 등록하고, 그것을 기본 인증 체계로 설정하면 됩니다.

builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer("Bearer");

API는 이와 반대로, 잘 되어 있기 때문에 문제가 없습니다.

// Authentication
builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(options =>
{
    options.TokenValidationParameters = new TokenValidationParameters()
    {
        ValidateIssuer = false,
        ValidateAudience = true,
        ValidateIssuerSigningKey = true,
        ValidateLifetime = true,
        ValidIssuer = builder.Configuration["Jwt:Issuer"],
        ValidAudience = builder.Configuration["Jwt:Audience"],
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"])),
        ClockSkew = TimeSpan.Zero, // 만료 시간 검증에 대한 여유 시간을 없앰       
    };
});

프로젝트를 보니, 실질 인증은 API가 담당하고, 블레이저 프로젝트의 AccountService 가 API에 접속하여 그 결과를 Constant.JwtToken 정적 변수에 담아 두면, 커스텀 AuthenticationStateProvider가 이를 사용하는 구조인 것 같습니다.

이 구조라면, 블레이저 서버에서 인증과 관련한 서비스/미들웨어와 이들과 협업하는 AuthorizeAttribute 를 사용할 이유가 없습니다.

현재 구조에서, 블레이저의 인증과 관련한 행위는 AuthenticationStateProvider를 사용해 특정 레이저 요소를 감추거나 보여 주는 것이 전부가 될 것입니다.
참고로, 이는 블레이저 Wasm (클라이언트 앱)에서 일반적으로 사용하는 방식이라 할 수 있습니다.
블레이저 관련한 코드 예제를 참고 하실 때는 클라이언트 코드인지 서버 코드인 지를 확인하는 게 좋습니다.

5 Likes

올려주신 프로젝트를 확인해보니

@BigSquare 님이 말씀하신 것처럼 우선 WebApi 프로젝트와 Blazer의 Program.cs 파일에
AddAuthentication()이 다릅니다.

한쪽은 Cookie이고 한쪽은 JWT이네요
WebApi 의 것을 Blazer 프로젝트 쪽으로 넣어서 해보시고
그 결과로 어떤 결과가 나오는지 알려주시면 도움 더 드릴 수 있을 것 같습니다!

2 Likes

스키마를
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
});

로 바꾸어도 에러가 똑같이 납니다. ㅡㅡ

1 Like

자세한 설명 감사드립니다.

말씀하신대로

web api program.cs
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(“Bearer”, options =>
{
options.TokenValidationParameters = new TokenValidationParameters()
{
ValidateIssuer = false,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
ValidIssuer = builder.Configuration[“Jwt:Issuer”],
ValidAudience = builder.Configuration[“Jwt:Audience”],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration[“Jwt:Key”])),
ClockSkew = TimeSpan.Zero, // 만료 시간 검증에 대한 여유 시간을 없앰
};
});

blazor 의 program.cs
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(“Bearer”);

스키마도 독같이 맞추었지만 이번에는 401 에러가 뜹니다.

web api + blazor server 조합은 정말 어려운거 같습니다.

1 Like
builder.Services.AddAuthentication(x =>
{
    x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(opts =>
{   
    opts.RequireHttpsMetadata = false;
    opts.SaveToken = true;
    opts.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
        ValidateIssuer = false,
        ValidateAudience = false,
        ValidateActor = false,
        //RequireExpirationTime = true, // 만료시간 값 필요
        //ValidateLifetime = true,      // 만료시간 값 검증
        //ClockSkew = TimeSpan.Zero,    // 기입된 만료시간에 +/- 없이 검증
        //LifetimeValidator = (before, expires, token, parameters) => expires > DateTime.UtcNow,
    };
    opts.Events = new JwtBearerEvents()
    {
        OnAuthenticationFailed = context =>
        {
            //
            // 여기 브레이크 포인트 걸어서 context에 어떤 데이터 들어가는지 확인할 수 있을까요?
            //            
            return Task.CompletedTask;
        },
        OnForbidden = context =>
        {
            // for debugging
            return Task.CompletedTask;
        }
    };
});

위와 같은 셈플 코드를 드리니 OnAuthenticationFailed 부분에 브레이크 포인트 걸어서 혹시 잡히는지 확인 해보실 수 있으세요?

1 Like

web api 프로젝트 말씀하시는거죠?

저 부분에 브레이크 포인트가 잡히질 않습니다.

1 Like

아니오~ [Authorize] 특성 추가한 블레이저 쪽에서요~
Context쪽에 혹시 더 자세한 정보가 있을 수도 있을 것 같아서요!

1 Like

서버가 401응답을 보낸 것입니다. 예외가 발생한다는 질문에 해결된 것이죠^^

어찌되었건 블레이저에서 그 코드를 지워보세요.
그럼 로그인 화면으로 갈 것입니다.

1 Like

웹 api 서버에서는 이상없이 잘 작동합니다.

문제는 blazor 서버에서 인증을 했는데도 401 이 뜹니다. ㅡㅡ

1 Like

잘 아시겠지만 401Unauthorized입니다 (401 Unauthorized - HTTP | MDN)

웹API와 블레이저가 같은 AddAuthentication()을 사용한다고 가정했을 때 이런 문제가 발생한더라면
JWT을 생성하는 쪽과 인증을 확인하려는 쪽에 JWT Secret이 다른 것이 아닌지도 확인해볼 필요가 있어 보입니다.

2 Likes
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=devihttps://localhost:7245/account/logoutce-width, initial-scale=1.0" />
    <base href="/" />
    <link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
    <link rel="stylesheet" href="app.css" />
    <link rel="stylesheet" href="Blazor.styles.css" />
    <link rel="icon" type="image/png" href="favicon.png" />
    <HeadOutlet @rendermode="new InteractiveServerRenderMode(prerender : false)" />
</head>

<body>
    <Routes @rendermode="new InteractiveServerRenderMode(prerender : false)" />
    <script src="_framework/blazor.web.js"></script>
</body>

</html>

처음에서 따로 수정한건 없고 블레이저 프로젝트에서 app.razor 내용의 라우트 랜더모드를 사전랜더링안함으로 설정하니 정상작동은 하지만 이것 역시 새로고침하면 페이지를 표시할 수 없음이라고 나오네요.

블레이저 넘 오렵네요. 랜더링모드에 따라서 되고 안되고 ㅡㅡ

뭐가 문제인지 진짜 모르겠네요.

401 은 블레이저 서버의 인가 미들웨어가 보낸 것입니다.

program.cs 를 아래와 같이 변경하시고,

using Blazor.Components;
using Blazor.Services.Accounts;
using Blazor.States;
using Microsoft.AspNetCore.Components.Authorization;

var builder = WebApplication.CreateBuilder(args);

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

// Add Start ---------------------------------------------------------------------------------------------------------------------
// builder.Services.AddAuthentication("bearer");

//builder.Services.AddAuthentication(options =>
//{
//    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
//    options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
//    options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
//})
//    .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
//    .AddJwtBearer(options =>
//    {
//        options.
//    });

//builder.Services.AddAuthorization();
builder.Services.AddCascadingAuthenticationState();


builder.Services.AddHttpClient("SystemApiClient", client =>
{
    client.BaseAddress = new Uri("https://localhost:7213"); // 원격 ip
});

builder.Services.AddScoped<IAccountService, AccountService>();
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthenticationStateProvider>();
// Add End ---------------------------------------------------------------------------------------------------------------------

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    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();

// Add Start ---------------------------------------------------------------------------------------------------------------------
//app.UseAuthentication();
//app.UseAuthorization();
// Add End ---------------------------------------------------------------------------------------------------------------------

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

app.Run();

인가 미들웨어를 지웠기 때문에, 이 미들웨어가 참조하는 Authorize 특성도 필요없습니다.
대신에, AuthenticationStateProvider에 의존해서 뷰를 작성해야 합니다.

예를 들어 카운터 페이지를 아래처럼 수정하는 것이죠.

@page "/counter"

@* @attribute [Authorize(Roles = "Admin")]*@
@rendermode InteractiveServer
<PageTitle>Counter</PageTitle>
<AuthorizeView>
    <Authorized>
        <h1>Counter</h1>

        <p role="status">Current count: @currentCount</p>

        <button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
    </Authorized>
    <NotAuthorized>
        <p > 로그인이 필요한 서비스입니다.</p>
    </NotAuthorized>
</AuthorizeView>


@code {
    // [CascadingParameter]
    // private Task<AuthenticationState> _AuthenticationState { get; set; }

    private int currentCount = 0;

    private void IncrementCount()
    {
        currentCount++;
    }
}

위의 코드는 아래의 조언대로 구성한 것입니다.

이 외에도 보호 대상 페이지의 OnInitializedAsync 에서 _AuthenticationState 를 테스트하고, 실패 시 로그인 페이지로 이동하도록 만드는 방법도 사용할 수도 있습니다.

만약, 블레이저 서버에 (인증/인가 미들웨어에 기반한) Authorize 특성을 적용하고 싶다면(Role 기반 인증 등을 편하게 사용하기 위해), AccountService 와 같은 역할을 하는 인증 핸들러를 보유한 켜스텀 인증 스킴을 정의하면 됩니다. 이 스킴은 인증/인가 미들웨어가 모두 사용하기에 프로젝트의 다른 뼈대 코드를 전혀 건드리지 않고도 API에 기반한 인증이 원활하게 동작합니다.

사실, 더 간단한 방법은 인증을 API가 아닌 블레이저 서버에 두는 것이죠.
이 경우에도, 인증이 아닌 서비스를 위해 API가 여전히 필요하다면, 블레이저 서버 프로젝트에 웹 API 관련 서비스를 등록하면 됩니다. 콘트롤러 기반의 API 는 아래의 설정으로,

builder.Services.AddControllers();

미니멀 API는 그냥 app.MatGet ~~ 같은 메서드를 쓰면 됩니다.

블레이저 서버가 인증을 처리할 때는, AuthenticationStateProvider 를 직접 파생하지 말고, 블레이저 서버를 위해 프레임워크가 제공하는 RevalidatingServerAuthenticationStateProvider 를 수정 없이 사용하거나, 파생하는 게 좋은데, (파생할 일이 거의 없지만서도) 이 객체는 SignalR 커넥션으로 인한 스코프 비일치 문제를 해결한 버전이기 때문입니다.

7 Likes

답변주셔서 감사합니다.

설명해주신 내용 잘 이해했습니다.

알려주신 내용으로 수정했습니다. 잘 작동합니다.

답변주신 모든 분들께 다시한번 감사드립니다.

블레이저 입문이라 앞으로도 많이 물어 볼거 같습니다.

6 Likes

댓글을 쭉 읽어보니 도움도 많이 되고 고생도 많이 하셨네요.

근데, Blazor Server가 내부적으로 SignalR로 클라이언트-서버 통신을 하는 것이기 때문에 Blazor + Web API로 하실 거라면 애초에 Blazor Server가 아닌 Blazor Wasm + Web API로 하셨셔야 했을 것 같습니다.

이 경우 Blazor wasm이 자바스크립트 역할을 하고 마치 자바스크립트에서 JWT로 Web API를 호출하는 것과 거의 동일합니다. 이렇게 프로젝트를 구성하고 Web API 쪽에는 JWT 인증으로 구성하시고 Blazor wasm 프로젝트에서는 Web API의 Login 메서드 등을 호출해서 서버로부터 JWT 토큰을 받아서 Web API 호출 시 사용하시면 됩니다.

이렇게 하시면 Blazor wasm의 Authorize 어트리뷰트와 Web API의 Authorize 어트리뷰트가 아마도 처음 생각하셨던 것 처럼 일관되게 작동했을 것입니다.

애초에 Blazor라는 기술이 React와 같은 자바스크립 SPA 대신 C#으로 구현하자는 것이 주 의도이기 때문에 Web API를 사용하실 거라면 Blazor wasm + Web API가 보다 자연스러우며, Blazor Server는 SPA 클라이언트 따로, Web API 서버 따로 만드는 대신 Blazor Server 하나로 만드는 것에 가깝습니다.

개인적으로 Blazor Server는 일종의 과도기 기술 - Blazor가 처음 나왔을 당시에는 IE가 존재했기 때문에 wasm을 지원하지 않는 IE를 지원하고, 서버 쪽에서 더 많은 기능을 처리하도록 함으로써 사양이 낮은 모바일 기기를 위한 것도 있기 때문에 Blazor가 결국 달성하려는 목표에 가까운 구조는 Blazor wasm + Web API가 더 자연스럽고 이치에 맞다고 생각합니다.

그리고 Blazor wasm + Web API가 좋은 점은 클라이언트단을 Blazor wasm 대신 다른 것으로 바꿀 수도 있다는 것입니다. 클라이언트 UI 로직과 서버쪽의 데이터 레이어를 완전히 분리함으로써 클라이언트-서버를 디커플링할 수가 있습니다. 예를 들어서 한 프로젝트에서 Blazor wasm으로 개발하다가 팀장이 이거 못써먹겠다고 React로 바꾸자고 해도 Web API 쪽은 그대로 사용이 가능합니다.

실제로, 제 경우 기존에 있던 ASP.NET MVC 5 (Core 아님) 프로젝트의 비즈니스 로직을 필요한 부분만Web API로 노출한 다음 Blazor wasm으로 UI를 개발함으로써 기존 서버 비즈니스 로직을 그대로 사용했네요. 즉 Blazor wasm + ASP.NET Web API 2 방식으로 성공적으로 기능 구현을 했었네요.

참고로 Blazor wasm + Web API 개발은 유데미에 아주 좋은 강좌가 있으니 처음 공부하시는 분들은 그거 보고 시작하셔도 좋을 것 같네요. Full Stack Hero ? 라는 아주 좋은 Blazor 보일러플레이트 프로젝트 소스도 Blazor wasm + Web API 구조로 되어 있습니다.

7 Likes

자세한 설명 감사드립니다.

한가지만 질문드릴께요.

웹어셈블리는 클라이언트로 모든 파일이 다운로드 된다고 하는데,

만들려는 사이트가 적성검사 사이트라서 적성검사 문제가 클라이언트 단으로 모두 다운로드가 되는게 맞나요? 문제가 유출이 되면 안되어서요.

답변해 주셨는데 또 질문을 드려서 죄송합니다.

1 Like