登录 Blazor 服务器端应用程序无法正常工作

SignIn for Blazor Server-Side app not working

我正在为 Asp.net 核心 3.0 Blazor 服务器端应用构建示例登录剃刀组件。每当代码到达 SignInAsyc 方法时,它似乎只是挂起或锁定,因为代码停止进一步执行。我还尝试通过使用 PasswordSignInAsync 方法来切换逻辑,这给了我完全相同的结果。所有代码都将在该方法之前执行,但随后会在执行该语句时冻结。我在这里错过了什么?

Razor 组件页面:

<div class="text-center">
    <Login FieldsetAttr="fieldsetAttr" UsernameAttr="usernameAttr" PasswordAttr="passwordInput"
           ButtonAttr="buttonAttr" ButtonText="Sign In" InvalidAttr="invalidAttr" />

</div>

@code {
    Dictionary<string, object> fieldsetAttr =
        new Dictionary<string, object>()
        {
            {"class", "form-group" }
        };

    Dictionary<string, object> usernameAttr =
        new Dictionary<string, object>()
        {
            {"class", "form-control" },
            {"type", "text" },
            {"placeholder", "Enter your user name here." }
        };

    Dictionary<string, object> passwordInput =
        new Dictionary<string, object>()
        {
            {"class", "form-control" },
            {"type", "password" }
        };

    Dictionary<string, object> buttonAttr =
        new Dictionary<string, object>()
        {
            {"type", "button" }
        };

    Dictionary<string, object> invalidAttr =
        new Dictionary<string, object>()
        {
            {"class", "" },
            {"style", "color: red;" }
        };

    Dictionary<string, object> validAttr =
        new Dictionary<string, object>()
        {
            {"class", "" },
            {"style", "color: green;" }
        };

}

Razor 组件:

@inject SignInManager<IdentityUser> signInManager
@inject UserManager<IdentityUser> userManager

<div @attributes="FormParentAttr">
    <form @attributes="LoginFormAttr">
        <fieldset @attributes="FieldsetAttr">
            <legend>Login</legend>
            <label for="usernameId">Username</label><br />
            <input @attributes="UsernameAttr" id="usernameId" @bind="UserName" /><br />
            <label for="upasswordId">Password</label><br />
            <input @attributes="PasswordAttr" id="passwordId" @bind="Password" /><br />
            <button @attributes="ButtonAttr" @onclick="@(async e => await LoginUser())">@ButtonText</button>
            @if (errorMessage != null && errorMessage.Length > 0)
            {
                <div @attributes="InvalidAttr">
                    @errorMessage
                </div>
            }
            else if(successMessage != null && successMessage.Length > 0)
            {
                <div @attributes="ValidAttr">
                    @successMessage
                </div>
            }
        </fieldset>
    </form>
</div>

@code {

    string successMessage = "";

    private async Task LoginUser()
    {
        if(!String.IsNullOrEmpty(UserName))
        {
            var user = await userManager.FindByNameAsync(UserName);
            var loginResult =
                await signInManager.CheckPasswordSignInAsync(user, Password, false);



            if(loginResult.Succeeded)
            {
                await signInManager.SignInAsync(user, true);
                successMessage = $"{UserName}, signed in.";
                errorMessage = "";
            }
            else
            {
                successMessage = "";
                errorMessage = "Username or password is incorrect.";
            }
        }
        else
        {
            successMessage = "";
            errorMessage = "Provide a username.";
        }
    }

    [Parameter]
    public Dictionary<string, object> FormParentAttr { get; set; }

    [Parameter]
    public Dictionary<string, object> LoginFormAttr { get; set; }

    [Parameter]
    public Dictionary<string, object> FieldsetAttr { get; set; }

    [Parameter]
    public Dictionary<string, object> UsernameAttr { get; set; }

    [Parameter]
    public Dictionary<string, object> PasswordAttr { get; set; }

    [Parameter]
    public Dictionary<string,object> ButtonAttr { get; set; }

    [Parameter]
    public Dictionary<string, object> InvalidAttr { get; set; }

    private string UserName { get; set; }
    private string Password { get; set; }

    [Parameter]
    public string ButtonText { get; set; }

    [Parameter]
    public Dictionary<string, object> ValidAttr { get;set; }

    public string errorMessage { get; set; }

}

基本上,发生这种情况是因为 SigninManger::SignInAsync() 实际上会尝试 通过 HTTP 发送 cookie 以指示该用户已经登录。但是当此时处理 Blazor 服务器端,根本没有可用的 HTTP Response,只有 WebSocket 连接 (SignalR)。

如何修复

简单来说,Signin就是持久化用户credentials/cookies/...,让WebApp知道客户端是谁。由于您使用的是 Blazor 服务器端,因此您的客户端正在 WebSocket 连接 中与服务器通信。无需通过 HTTP 发送 cookie。因为你的 WebApp 已经知道当前用户是谁。

要解决此问题,请先注册一个 IHostEnvironmentAuthenticationStateProvider 服务:

services.AddScoped<AuthenticationStateProvider, RevalidatingIdentityAuthenticationStateProvider<IdentityUser>>();
services.AddScoped<IHostEnvironmentAuthenticationStateProvider>(sp => {
    // this is safe because 
    //     the `RevalidatingIdentityAuthenticationStateProvider` extends the `ServerAuthenticationStateProvider`
    var provider = (ServerAuthenticationStateProvider) sp.GetRequiredService<AuthenticationStateProvider>();
    return provider;
});

然后创建一个主体并替换旧主体。

@inject AuthenticationStateProvider AuthenticationStateProvider
@inject IHostEnvironmentAuthenticationStateProvider HostAuthentication
...

var user = await userManager.FindByNameAsync(UserName);
var valid= await signInManager.UserManager.CheckPasswordAsync(user, Password);

if (valid)
{
    var principal = await signInManager.CreateUserPrincipalAsync(user);

    var identity = new ClaimsIdentity(
        principal.Claims,
        Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme
    );
    principal = new System.Security.Claims.ClaimsPrincipal(identity);
    signInManager.Context.User = principal;
    HostAuthentication.SetAuthenticationState(Task.FromResult(new AuthenticationState(principal)));

    // now the authState is updated
    var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();

    successMessage = $"{UserName}, signed in.";
    errorMessage = "";

}
else
{
    successMessage = "";
    errorMessage = "Username or password is incorrect.";
}

演示

并检查 authState:

评论中讨论的 问题之一是在手动刷新、会话结束或 link 导致刷新后保持用户状态。这将丢失用户状态,因为 cookie 值未设置到客户端浏览器,这意味着下一个 HTTP 请求不包含 cookie。一种解决方案是使用允许将 cookie 发送到客户端浏览器的静态 login/out 页面。

此方法改为使用 JS 将 cookie 写入客户端浏览器,让 Blazor 处理一切。我 运行 遇到了一些 cookie 设置不正确的问题,因为我误解了 AddCookie() 在 Startup 中如何将选项添加到 DI 容器。它使用 IOptionsMonitor 来使用命名选项,使用 Scheme 作为键。

我修改了登录代码以调用将保存 cookie 的 JS。您可以 运行 在注册新用户或登录现有用户后执行此操作。

确保你 DI IOptionsMonitor<CookieAuthenticationOptions>,允许你解析命名选项,使用 Scheme 作为键。确保您使用 .Get(schemeName) 而不是 .CurrentValue,否则您的 TicketDataFormat(和其他设置)将不正确,因为它将使用默认值。我花了几个小时才意识到这一点。

注:IOptionsMonitor<CookieAuthenticationOptions>来自调用services.AddAuthentication().AddCookie()。下面提供了一个示例。

    _cookieAuthenticationOptions = cookieAuthenticationOptionsMonitor.Get("MyScheme");
    ...
    private async Task SignInAsync(AppUser user, String password)
    {
        //original code from above answer
        var principal = await _signInManager.CreateUserPrincipalAsync(user);

        var identity = new ClaimsIdentity(
            principal.Claims,
            "MyScheme"
        );
        principal = new ClaimsPrincipal(identity);
        _signInManager.Context.User = principal;
        _hostAuthentication.SetAuthenticationState(Task.FromResult(new AuthenticationState(principal)));

        // this is where we create a ticket, encrypt it, and invoke a JS method to save the cookie
        var ticket = new AuthenticationTicket(principal, null, "MyScheme");
        var value = _cookieAuthenticationOptions.TicketDataFormat.Protect(ticket);
        await _jsRuntime.InvokeVoidAsync("blazorExtensions.WriteCookie", "CookieName", value, _cookieAuthenticationOptions.ExpireTimeSpan.TotalDays);
    }

然后我们:

    window.blazorExtensions = {

        WriteCookie: function (name, value, days) {

            var expires;
            if (days) {
                var date = new Date();
                date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
                expires = "; expires=" + date.toGMTString();
            }
            else {
                expires = "";
            }
            document.cookie = name + "=" + value + expires + "; path=/";
        }
    }

这将成功地将 cookie 写入客户端的浏览器。如果您遇到问题,请确保您的 Startup 使用相同的方案名称。如果不这样做,那么普通的 cookie 身份验证系统将无法正确解析回已编码的主体:

        services.AddIdentityCore<AppUser>()
            .AddRoles<IdentityRole>()
            .AddEntityFrameworkStores<AppDbContext>()
            .AddSignInManager();

        services.AddAuthentication(options =>
        {
            options.DefaultScheme = "MyScheme";
        }).AddCookie("MyScheme", options =>
        {
            options.Cookie.Name = "CookieName";
        });

完美主义者,也可以用同样的方式实现注销:

    private async Task SignOutAsync()
    {
        var principal = _signInManager.Context.User = new ClaimsPrincipal(new ClaimsIdentity());
        _hostAuthentication.SetAuthenticationState(Task.FromResult(new AuthenticationState(principal)));

        await _jsRuntime.InvokeVoidAsync("blazorExtensions.DeleteCookie", _appInfo.CookieName);

        await Task.CompletedTask;
    }

和 JS:

    window.blazorExtensions = {
        DeleteCookie: function (name) {
            document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:01 GMT";
        }
    }