服务器端 Blazor 中的 OIDC 身份验证

OIDC authentication in server-side Blazor

我使用了这个方法,但不知何故它不对,因为 @attribute [AllowAnonymous] 并没有真正起作用,所以我使用 [Authorized] 属性而不是 [AllowAnonymous] 然后删除 RequireAuthenticatedUser 但是OIDC 不会将客户端重定向到服务器登录页面。

我检查了 SteveSanderson github article 关于 blazor 中的身份验证和授权,但他没有谈论 OIDC。

那我该如何处理呢?

启动class:

services.AddAuthentication(config =>
{
    config.DefaultScheme = "Cookie";
    config.DefaultChallengeScheme = "oidc";
})
    .AddCookie("Cookie")
    .AddOpenIdConnect("oidc", config =>
    {
        config.Authority = "https://localhost:44313/";
        config.ClientId = "client";
        config.ClientSecret = "secret";
        config.SaveTokens = true;
        config.ResponseType = "code";
        config.SignedOutCallbackPath = "/";
        config.Scope.Add("openid");
        config.Scope.Add("api1");
        config.Scope.Add("offline_access");
    });

services.AddMvcCore(options =>
{
    var policy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser() // site-wide auth
        .Build();
    options.Filters.Add(new AuthorizeFilter(policy));
});

对于服务器端 Blazor,身份验证发生在托管 Blazor 应用程序的 Razor 页面 上。对于默认模板,这是 _Host.cshtml Razor 页面,它被配置为 服务器端 路由的后备页面。由于该页面就像一个普通的 Razor 页面,您可以在那里使用 [Authorize][AllowAnonymous] 属性。

您向 _Host.cshtml 申请的任何授权都会影响对 Blazor 应用程序本身的一般访问权限的授权方式。如果您只希望经过身份验证的用户访问该应用程序,您应该需要授权;如果您希望 任何 未经身份验证的用户访问应用程序,您无法保护应用程序访问本身。

页面的授权并不意味着您不能在您的应用程序中获得更细粒度的授权。您仍然可以对应用程序中的特定组件使用不同的规则和策略。为此,您可以使用 <AuthorizeView> 组件。

服务器端 Blazor 可能有两种常见情况:

  • 仅限经过身份验证的用户访问整个 Blazor 应用程序。未通过身份验证的用户应立即进行身份验证(例如使用 OIDC),以免匿名用户访问应用程序。

    在这种情况下,通过 [Authorize] 属性或使用 AddRazorPages() 调用中的约定要求经过身份验证的用户,足以保护 _Host.cshtml

    在未经身份验证的情况下访问 Blazor 应用程序时,默认授权中间件将导致身份验证质询并重定向到 OIDC 登录。

  • 未经身份验证的用户应该能够访问 Blazor 应用程序,但 Blazor 应用程序将使用 <AuthorizeView>IAuthorizationService.

    在这种情况下,_Host.cshtml 不能被保护,因为匿名用户需要访问它。这也意味着作为 Razor 页面的一部分运行的默认授权中间件不会执行任何操作。所以你必须自己应对挑战。

    执行此操作的“简单”方法是向不同的服务器端路由提供登录 link,然后触发身份验证质询并重定向到 OIDC 登录。例如,您可以有一个像这样的 MVC 操作:

    [HttpGet("/login")]
    public IActionResult Login()
        => Challenge();
    

    在您的 Blazor 应用程序中,您现在可以向此路由添加 link 并允许用户以这种方式登录:

    <AuthorizeView>
      <Authorized>
        Signed in as @context.User.Identity.Name.
      </Authorized>
      <NotAuthorized>
        <a href="/login">Sign in here</a>
      </NotAuthorized>
    </AuthorizeView>
    

以下是问题的完整有效解决方案:

首先,您需要提供身份验证质询请求机制,以启用重定向到身份验证代理(如 IdentityServer)的功能。这仅适用于 HttpContext,它在 SignalR(Blazor Server App)中不可用。为了解决这个问题,我们将添加几个 HttpContext 可用的 Razor 页面。更多答案...

创建 Blazor 服务器应用。

安装包Microsoft.AspNetCore.Authentication.OpenIdConnect - 版本 3.1.0 或更高版本。

创建一个名为 LoginDisplay (LoginDisplay.razor) 的组件,并将其放置在 共享文件夹。该组件在MainLayout组件中使用:

<AuthorizeView>
    <Authorized>
        <a href="logout">Hello, @context.User.Identity.Name !</a>
        <form method="get" action="logout">
            <button type="submit" class="nav-link btn btn-link">Log 
                   out</button>
        </form>
    </Authorized>
    <NotAuthorized>
        <a href="login?redirectUri=/">Log in</a>
    </NotAuthorized>
 </AuthorizeView>

将 LoginDisplay 组件添加到 MainLayout 组件,就在关于 锚元素,像这样

<div class="top-row px-4">
    <LoginDisplay />
    <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
</div>

注意:为了将登录和注销请求重定向到 IdentityServer,我们必须创建两个 Razor 页面,如下所示:

  1. 创建一个 Login Razor 页面 Login.cshtml (Login.cshtml.cs) 并将它们放在 Pages 文件夹中,如下所示: Login.cshtml.cs

using Microsoft.AspNetCore.Authentication;
 using Microsoft.AspNetCore.Authentication.OpenIdConnect;
 using Microsoft.AspNetCore.Authentication.Cookies;
 using Microsoft.IdentityModel.Tokens;

public class LoginModel : PageModel
{
    public async Task OnGet(string redirectUri)
    {
        await HttpContext.ChallengeAsync("oidc", new 
            AuthenticationProperties { RedirectUri = redirectUri } );
    }  
}

此代码启动您在启动 class 中定义的 Open Id Connect 身份验证方案的质询。

  1. 创建一个 Logout Razor 页面 Logout.cshtml (Logout.cshtml.cs) 并将它们也放在 Pages 文件夹中: Logout.cshtml.cs

using Microsoft.AspNetCore.Authentication;

public class LogoutModel : PageModel
 {
    public async Task<IActionResult> OnGetAsync()
    {
        await HttpContext.SignOutAsync();
        return Redirect("/");
    }
}

此代码将您注销,将您重定向到 Blazor 应用的主页。

用以下代码替换App.razor中的代码:

@inject NavigationManager NavigationManager

<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Program).Assembly">
    <Found Context="routeData">
        <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
            <NotAuthorized>
                @{
                    var returnUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
                    
                    NavigationManager.NavigateTo($"login?redirectUri={returnUrl}", forceLoad: true);
                    
                }

            </NotAuthorized>
            <Authorizing>
                Wait...
            </Authorizing>
        </AuthorizeRouteView>
    </Found>
    <NotFound>

        <LayoutView Layout="@typeof(MainLayout)">
            <p>Sorry, there's nothing at this address.</p>
        </LayoutView>

    </NotFound>

</Router>
</CascadingAuthenticationState>

将 Startup class 中的代码替换为以下代码:

using Microsoft.AspNetCore.Authentication.OpenIdConnect; 
using Microsoft.AspNetCore.Authorization; 
using Microsoft.AspNetCore.Mvc.Authorization; 
using System.Net.Http;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.IdentityModel.Tokens;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Logging;


public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    // This method gets called by the runtime. Use this method to add services to the container.
    // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddRazorPages();
        services.AddServerSideBlazor();
        services.AddAuthorizationCore();
        services.AddSingleton<WeatherForecastService>();
                    
        services.AddAuthentication(sharedOptions =>
        {
            sharedOptions.DefaultAuthenticateScheme = 
                 CookieAuthenticationDefaults.AuthenticationScheme;
            sharedOptions.DefaultSignInScheme = 
                CookieAuthenticationDefaults.AuthenticationScheme;
            sharedOptions.DefaultChallengeScheme = 
               OpenIdConnectDefaults.AuthenticationScheme;
        })
        .AddCookie()
        .AddOpenIdConnect("oidc", options =>
         {
             options.Authority = "https://demo.identityserver.io/";
             options.ClientId = "interactive.confidential.short"; 
             options.ClientSecret = "secret";
             options.ResponseType = "code";
             options.SaveTokens = true;
             options.GetClaimsFromUserInfoEndpoint = true;
             options.UseTokenLifetime = false;
             options.Scope.Add("openid");
             options.Scope.Add("profile");
             options.TokenValidationParameters = new 
                    TokenValidationParameters
                    {
                        NameClaimType = "name"
                    };
                    
             options.Events = new OpenIdConnectEvents
             {
               OnAccessDenied = context =>
                        {
                          context.HandleResponse();
                          context.Response.Redirect("/");
                          return Task.CompletedTask;
                       }
       };
 });

}


  // This method gets called by the runtime. Use this method to configure 
     the HTTP request pipeline.
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        else
        {
            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.UseEndpoints(endpoints =>
        {
            endpoints.MapBlazorHub();
            endpoints.MapFallbackToPage("/_Host");
        });
    }
}

重要提示:在上面的所有代码示例中,您必须根据需要添加 using 语句。其中大部分是默认提供的。此处提供的 using 是启用身份验证和授权流程所必需的。

  • 运行 您的应用程序,单击登录按钮进行身份验证。您将被重定向到 IdentityServer 测试服务器,该服务器允许您执行 OIDC 登录。您可以输入用户名:bob 和密码bob,然后单击“确定”按钮后,您将被重定向到您的主页。另请注意,您可以使用外部登录提供程序 Google(试试看)。请注意,在您使用身份服务器登录后,LoginDisplay 组件会显示字符串 "Hello, <your user name>".

注意:当您正在试验您的应用程序时,如果您想被重定向到身份服务器的登录页面,您应该清除浏览数据,否则,您的浏览器可能会使用缓存数据。请记住,这是一种基于 cookie 的授权机制...

请注意,按照此处创建的登录机制不会使您的应用比以前更安全。任何用户都无需登录即可访问您的网络资源。为了保护网站的某些部分,您还必须实施授权,通常情况下,经过身份验证的用户被授权访问受保护的资源,除非实施了其他措施,例如角色、策略等。以下是如何进行的演示您可以保护您的 Fetchdata 页面免受未经授权的用户访问(同样,经过身份验证的用户被视为有权访问 Fetchdata 页面)。

在 Fetchdata 组件页面的顶部,为 Authorize 属性添加 @attribute 指令,如下所示:@attribute [Authorize] 当未经身份验证的用户尝试访问 Fetchdata 页面时,将执行 AuthorizeRouteView.NotAuthorized 委托 属性,因此我们可以添加一些代码将用户重定向到同一身份服务器的登录页面以进行身份​​验证。

NotAuthorized 元素中的代码如下所示:

<NotAuthorized>
    @{
        var returnUrl = 
        NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
        NavigationManager.NavigateTo($"login?redirectUri= 
                              {returnUrl}", forceLoad: true);
     }
</NotAuthorized>

这会检索您尝试访问的最后一个页面的 url,即 Fetchdata 页面,然后导航到执行密码挑战的 Login Razor 页面,即用户被重定向到身份服务器的登录页面进行身份验证。

用户通过身份验证后,他将被重定向到 Fetchdata 页面。

我们怎样才能让 AuthorizeView Roles="SomeRole" 工作? Roles= 或 Profile= 均未使用 .razor 页面。

此外,如果我们尝试 user.IsInRole("SomeRole"),它总是给出 False。