欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

【翻译】使用WebApi和Asp.Net Core Identity 认证 Blazor WebAssembly(Blazor客户端应用)

程序员文章站 2023-11-08 20:49:52
在 Blazor WebAssembly 应用中,可以绕过授权检查,因为用户可以修改所有客户端代码。 所有客户端应用程序技术都是如此,其中包括 JavaScript SPA 框架或任何操作系统的本机应用程序。始终对客户端应用程序访问的任何 API 终结点内的服务器执行授权检查。 ......

偶然间在更新jetbrains rider时候发现blazor这个框架。

研究下。

blazor webassembly应用中的授权

在文档中提到了

在 blazor webassembly 应用中,可以绕过授权检查,因为用户可以修改所有客户端代码。 所有客户端应用程序技术都是如此,其中包括 javascript spa 框架或任何操作系统的本机应用程序。

始终对客户端应用程序访问的任何 api 终结点内的服务器执行授权检查。

google 找到了本文(原地址:https://chrissainty.com/securing-your-blazor-apps-authentication-with-clientside-blazor-using-webapi-aspnet-core-identity/)

由于该框架已经有所更新,翻译中有些内容我根据实际情况做了更改。

设置:创建解决方案

选择blazor应用

【翻译】使用WebApi和Asp.Net Core Identity 认证 Blazor WebAssembly(Blazor客户端应用)

项目名称

【翻译】使用WebApi和Asp.Net Core Identity 认证 Blazor WebAssembly(Blazor客户端应用)

选择blazor webassembly app(这里要勾选asp.net core host),如果找不到blazor webassembly app,请先在命令行执行以下命令:

dotnet new -i microsoft.aspnetcore.blazor.templates::3.1.0-preview1.19508.20

【翻译】使用WebApi和Asp.Net Core Identity 认证 Blazor WebAssembly(Blazor客户端应用)

解决方案创建之后,我们将开始对authenticationwithblazorwebassembly.server这个项目进行一些更改。

配置webapi

在配置webapi之前我先安装一些nuget包:

    <packagereference include="microsoft.aspnetcore.authentication.jwtbearer" version="3.0.0" />
    <packagereference include="microsoft.aspnetcore.blazor.server" version="3.0.0-preview9.19465.2" />
    <packagereference include="microsoft.aspnetcore.diagnostics.entityframeworkcore" version="3.0.0" />
    <packagereference include="microsoft.aspnetcore.identity.entityframeworkcore" version="3.0.0" />
    <packagereference include="microsoft.aspnetcore.identity.ui" version="3.0.0" />
    <packagereference include="microsoft.entityframeworkcore.sqlserver" version="3.0.0" />
    <packagereference include="microsoft.entityframeworkcore.tools" version="3.0.0">
    <packagereference include="microsoft.visualstudio.web.codegeneration.design" version="3.0.0" />

设置identity数据库:连接字符串

在进行任何设置之前,数据库方面需要一个连接字符串。这通常是保存在appsettings.json中的,但blazor托管模版并未提供此文件,所以我们需要手动添加此文件。

在authenticationwithblazorwebassembly.server项目右键添加 -> 新建项,然后选择应用设置文件

{
  "connectionstrings": {
    "defaultconnection": "server=(localdb)\\mssqllocaldb;database=authenticationwithblazorwebassembly;trusted_connection=true;multipleactiveresultsets=true"
  }
}

该文件带有一个已经设置好的连接字符串,你可以随时将其指向需要的地方。我们只需要添加一个数据库名就可以了,其余的保持默认值。

设置identity数据库:dbcontext

在authenticationwithblazorwebassembly.server项目跟目录创建一个名为data的目录,然后使用下面代码添加一个名为applicationdbcontext的类文件。

    public class applicationdbcontext : identitydbcontext
    {
        public applicationdbcontext(dbcontextoptions options) : base(options) {
        }
    }

 因为我们使用identity需要将信息存储在数据库中,所以我们不是从dbcontext继承,而是从identitydbcontext继承。identitydbcontext基类包含ef配置管理identity数据库表需要的所有配置。

设置identity数据库:注册服务

startup类中,我们需要添加一个构造函数,接收iconfiguration参数和一个属性来存储它。iconfiguration允许我们访问appsettings.json文件,如:连接字符串。

 

        public iconfiguration configuration { get; }

        public startup(iconfiguration configuration)
        {
            configuration = configuration;
        }

接下来我们将以下代码添加到configureservices方法的顶部。

        public void configureservices(iservicecollection services)
        {
            services.adddbcontext<applicationdbcontext>(options =>
                options.usesqlserver(configuration.getconnectionstring("defaultconnection")));

            services.adddefaultidentity<identityuser>()
                .addentityframeworkstores<applicationdbcontext>();

      //这里省略掉其他代码
        }

这里两行代码将applicationdbcontext添加到服务集合中。然后为asp.net core identity注册各种服务并通过applicationdbcontext使用entity framework作为数据存储。

设置identity数据库:创建数据库

现在可以为数据库创建初始迁移。在程序包管理器控制台运行以下命令。

add-migration createidentityschema -o data/migations

命令运行完成,你应该能在datamigrations文件夹中看到迁移文件。在控制台中运行命令update-database将迁移应用到数据库。

在运行迁移命令时遇到任何问题,请确保在程序包管理器中选择authenticationwithblazorwebassembly.server项目作为默认项目。

启用身份验证:注册服务

接下来在api中启用身份验证。同样,在configureservices中,在上一节添加的代码之后添加以下代码。

public void configureservices(iservicecollection services)
{
  //这里省略到其他代码
  services.addauthentication(jwtbearerdefaults.authenticationscheme)
                .addjwtbearer(options =>
                {
                    options.tokenvalidationparameters = new tokenvalidationparameters
                    {
                        validateissuer = true,
                        validateaudience = true,
                        validatelifetime = true,
                        validateissuersigningkey = true,
                        validissuer = configuration["jwtissuer"],
                        validaudience = configuration["jwtaudience"],
                        issuersigningkey = new symmetricsecuritykey(encoding.utf8.getbytes(configuration["jwtsecuritykey"]))
                    };
                });
  //这里省略掉其他代码
}

上面代码想服务容器添加和设置一些身份验证所需的服务。然后为json web tokens(jwt)添加处理程序,并配置接收到的jwts应该如何验证。你可以根据需求调整这些设置。

启用身份验证:应用程序设置

有一些设置要从appsettings.json文件中加载。

  • configuration["jwtissuer"]
  • configuration["jwtaudience"]
  • configuration["jwtsecuritykey"]

我们还未将它们添加到appsettings文件中。现在添加它们并添加一个设置用来控制令牌的持续时间,稍后我们会使用这个设置。

"jwtsecuritykey": "random_key_must_not_be_shared",
"jwtissuer": "https://localhost",
"jwtaudience": "https://localhost",
"jwtexpiryindays": 1,

保证jwtsecuritykey 的安全是非常重要的,因为这是用来对api产生的令牌签名的,如果泄露那么你的应用程序将不在安全。

由于我们在本地运行所有内容,所以我将issueraudience设置为localhost。如果在生产环境使用它,我们需要将issuer 设置为api运行的域名,将audience设置为客户端应用程序运行的域名。

启用身份验证:添加中间件

最后,我们需要在configure 方法中将必要的中间件添加到管道中。这将在api中启用身份验证和授权功能。将以下代码添加到app.useendpoints中间件前面。

app.useauthentication();
app.useauthorization();

这就是startup类所需要的所有东西。现在api已经启用了身份验证。

你可以通过向weatherforecastcontroller中的get方法添加[authorize]属性来测试一切是否正常。然后启用应用程序并导航到fetch data页面,应该不会加载任何数据,应该会在控制台中看到401错误。

添加账户(account)控制器

为了让人们登录到我们的应用程序,他们需要能够注册。我们将添加一个帐户控制器,它将负责创建新帐户。

    [route("api/[controller]")]
    [apicontroller]
    public class accountscontroller : controllerbase
    {
        //private static usermodel loggedoutuser = new usermodel { isauthenticated = false };

        private readonly usermanager<identityuser> _usermanager;

        public accountscontroller(usermanager<identityuser> usermanager)
        {
            _usermanager = usermanager;
        }

        [httppost]
        public async task<iactionresult> post([frombody]registermodel model)
        {
            var newuser = new identityuser { username = model.email, email = model.email };

            var result = await _usermanager.createasync(newuser, model.password);

            if (!result.succeeded)
            {
                var errors = result.errors.select(x => x.description);

                return badrequest(new registerresult { successful = false, errors = errors });

            }

            return ok(new registerresult { successful = true });
        }
    }

 

post操作使用asp.net core identity从registermodel来创建系统的新用户。

我们还没用添加注册模型,现在使用以下代码添加到authenticationwithblazorwebassembly.shared项目中,稍后我们的blazor应用程序将会使用到它。

    public class registermodel
    {
        [required]
        [emailaddress]
        [display(name = "email")]
        public string email { get; set; }

        [required]
        [stringlength(100, errormessage = "the {0} must be at least {2} and at max {1} characters long.", minimumlength = 6)]
        [datatype(datatype.password)]
        [display(name = "password")]
        public string password { get; set; }

        [datatype(datatype.password)]
        [display(name = "confirm password")]
        [compare("password", errormessage = "the password and confirmation password do not match.")]
        public string confirmpassword { get; set; }
    }

如果一切顺利,则会返回一个成功的registerresult,否则会返回一个失败的registerresult,我们一样将它添加到authenticationwithblazorwebassembly.shared项目中。

    public class registerresult
    {
        public bool successful { get; set; }
        public ienumerable<string> errors { get; set; }
    }

添加登录(login)控制器

现在我们有了用户注册的方式,我们还需要用户登录方式。

 [route("api/[controller]")]
    [apicontroller]
    public class logincontroller : controllerbase
    {
        private readonly iconfiguration _configuration;
        private readonly signinmanager<identityuser> _signinmanager;

        public logincontroller(iconfiguration configuration,
            signinmanager<identityuser> signinmanager)
        {
            _configuration = configuration;
            _signinmanager = signinmanager;
        }

        [httppost]
        public async task<iactionresult> login([frombody] loginmodel login)
        {
            var result = await _signinmanager.passwordsigninasync(login.email, login.password, false, false);

            if (!result.succeeded) return badrequest(new loginresult { successful = false, error = "username and password are invalid." });

            var claims = new[]
            {
                new claim(claimtypes.name, login.email)
            };

            var key = new symmetricsecuritykey(encoding.utf8.getbytes(_configuration["jwtsecuritykey"]));
            var creds = new signingcredentials(key, securityalgorithms.hmacsha256);
            var expiry = datetime.now.adddays(convert.toint32(_configuration["jwtexpiryindays"]));

            var token = new jwtsecuritytoken(
                _configuration["jwtissuer"],
                _configuration["jwtaudience"],
                claims,
                expires: expiry,
                signingcredentials: creds
            );

            return ok(new loginresult { successful = true, token = new jwtsecuritytokenhandler().writetoken(token) });
        }
    }

登录控制器(login controller)使用asp.net core identity signinmanger验证用户名和密码。如果它们都正确,则生成一个新的json web token并在loginresult返回给客户端。

像之前一样,我们需要将loginmodelloginresult添加到authenticationwithblazorwebassembly.shared项目中。

    public class loginmodel
    {
        [required]
        public string email { get; set; }

        [required]
        public string password { get; set; }

        public bool rememberme { get; set; }
    }
    public class loginresult
    {
        public bool successful { get; set; }
        public string error { get; set; }
        public string token { get; set; }
    }

这就是api需要的所有东西。我们现在已经将其配置为通过json web tokens进行身份验证。接下来我们需要为blazor webassembly(客户端)应用程序添加注册新用户和登录控制器。

配置blazor客户端

接下来我们关注blazor。首先需要安装blazored.localstorage,我们稍后将需要它在登录时从api中持久化验证令牌。

我们还需要在app组件中使用authorizerouteview组件替换routeview组件(这里需要使用microsoft.aspnetcore.components.authorization nuget包并在_imports.razor添加@using microsoft.aspnetcore.components.authorization)。

<router appassembly="@typeof(program).assembly">
    <found context="routedata">
        <authorizerouteview routedata="@routedata" defaultlayout="@typeof(mainlayout)" />
    </found>
    <notfound>
        <layoutview layout="@typeof(mainlayout)">
            <p>sorry, there's nothing at this address.</p>
        </layoutview>
    </notfound>
</router>

此组件提供类型为task<authenticationstate>的级联参数。authorizeview通过使用它来确定当前用户的身份验证状态。

但是任何组件都可以请求参数并使用它来执行过程逻辑,例如:

@page "/"

<button @onclick="@logusername">log username</button>

@code {
    [cascadingparameter]
    private task<authenticationstate> authenticationstatetask { get; set; }

    private async task logusername()
    {
        var authstate = await authenticationstatetask;
        var user = authstate.user;

        if (user.identity.isauthenticated)
        {
            console.writeline($"{user.identity.name} is authenticated.");
        }
        else
        {
            console.writeline("the user is not authenticated.");
        }
    }
}

创建自定义authenticationstateprovider

因为我们使用blazor webassembly,所以我们需要为authenticationstateprovider提供自定义实现。因为在客户端应用程序有太多的选项,所以无法设计一个适用于所有人的默认类。

我们需要重写getauthenticationstateasync方法。在此方法中,我们需要确定当前用户是否经过身份验证。我们还将添加两个辅助方法,当用户登录或注销时,我们将使用这些方法更新身份验证状态。

public class apiauthenticationstateprovider : authenticationstateprovider
    {
        private readonly httpclient _httpclient;
        private readonly ilocalstorageservice _localstorage;

        public apiauthenticationstateprovider(httpclient httpclient, ilocalstorageservice localstorage)
        {
            _httpclient = httpclient;
            _localstorage = localstorage;
        }

        public override async task<authenticationstate> getauthenticationstateasync()
        {
            var savedtoken = await _localstorage.getitemasync<string>("authtoken");

            if (string.isnullorwhitespace(savedtoken))
            {
                return new authenticationstate(new claimsprincipal(new claimsidentity()));
            }

            _httpclient.defaultrequestheaders.authorization = new authenticationheadervalue("bearer", savedtoken);

            return new authenticationstate(new claimsprincipal(new claimsidentity(parseclaimsfromjwt(savedtoken), "jwt")));
        }

        public void markuserasauthenticated(string token)
        {
            var authenticateduser = new claimsprincipal(new claimsidentity(parseclaimsfromjwt(token), "jwt"));
            var authstate = task.fromresult(new authenticationstate(authenticateduser));
            notifyauthenticationstatechanged(authstate);
        }

        public void markuserasloggedout()
        {
            var anonymoususer = new claimsprincipal(new claimsidentity());
            var authstate = task.fromresult(new authenticationstate(anonymoususer));
            notifyauthenticationstatechanged(authstate);
        }

        private ienumerable<claim> parseclaimsfromjwt(string jwt)
        {
            var claims = new list<claim>();
            var payload = jwt.split('.')[1];
            var jsonbytes = parsebase64withoutpadding(payload);
            var keyvaluepairs = jsonserializer.deserialize<dictionary<string, object>>(jsonbytes);

            keyvaluepairs.trygetvalue(claimtypes.role, out object roles);

            if (roles != null)
            {
                if (roles.tostring().trim().startswith("["))
                {
                    var parsedroles = jsonserializer.deserialize<string[]>(roles.tostring());

                    foreach (var parsedrole in parsedroles)
                    {
                        claims.add(new claim(claimtypes.role, parsedrole));
                    }
                }
                else
                {
                    claims.add(new claim(claimtypes.role, roles.tostring()));
                }

                keyvaluepairs.remove(claimtypes.role);
            }

            claims.addrange(keyvaluepairs.select(kvp => new claim(kvp.key, kvp.value.tostring())));

            return claims;
        }

        private byte[] parsebase64withoutpadding(string base64)
        {
            switch (base64.length % 4)
            {
                case 2: base64 += "=="; break;
                case 3: base64 += "="; break;
            }
            return convert.frombase64string(base64);
        }
    }

这里有很多代码,让我们一步一步来分析。

cascadingauthenticationstate组件调用getauthenticationstateasync方法来确定当前用户是否经过验证。

上面的代码,我们检查local storge是否有验证令牌。如果local storge中没有令牌,那么我们将返回一个新的authenticationstate,其中包含一个空的claimsprincipal。这就说明当前用户用户没有经过身份验证。

如果有令牌,读取并设置httpclient的默认authorization header,并返回一个包含claimsprincipal新的authenticationstate的令牌声明。该声明(claims)使用parseclaimsfromjwt方法从令牌中提取。此方法解码令牌并返回其中包含的声明。

markuserasauthenticated辅助方法用于登录时调用notifyauthenticationstatechanged方法,该方法触发authenticationstatechanged事件。这将通过cascadingauthenticationstate组件级联新的身份验证状态。

markuserasloggedout用于用户注销时。

auth service

auth service将在组件中注册用户并登录到应用程序和用户注销使用。

public class authservice : iauthservice
    {
        private readonly httpclient _httpclient;
        private readonly authenticationstateprovider _authenticationstateprovider;
        private readonly ilocalstorageservice _localstorage;

        public authservice(httpclient httpclient,
            authenticationstateprovider authenticationstateprovider,
            ilocalstorageservice localstorage)
        {
            _httpclient = httpclient;
            _authenticationstateprovider = authenticationstateprovider;
            _localstorage = localstorage;
        }

        public async task<registerresult> register(registermodel registermodel)
        {
            var result = await _httpclient.postjsonasync<registerresult>("api/accounts", registermodel);

            return result;
        }

        public async task<loginresult> login(loginmodel loginmodel)
        {
            var loginasjson = jsonserializer.serialize(loginmodel);
            var response = await _httpclient.postasync("api/login", new stringcontent(loginasjson, encoding.utf8, "application/json"));
            var loginresult = jsonserializer.deserialize<loginresult>(await response.content.readasstringasync(), new jsonserializeroptions { propertynamecaseinsensitive = true });

            if (!response.issuccessstatuscode)
            {
                return loginresult;
            }

            await _localstorage.setitemasync("authtoken", loginresult.token);
            ((apiauthenticationstateprovider)_authenticationstateprovider).markuserasauthenticated(loginresult.token);
            _httpclient.defaultrequestheaders.authorization = new authenticationheadervalue("bearer", loginresult.token);

            return loginresult;
        }

        public async task logout()
        {
            await _localstorage.removeitemasync("authtoken");
            ((apiauthenticationstateprovider)_authenticationstateprovider).markuserasloggedout();
            _httpclient.defaultrequestheaders.authorization = null;
        }
    }

register方法提交registermodel给accounts controller并返回registerresult给调用者。

login 方法类似于register 方法,它将loginmodel 发送给login controller。但是,当返回一个成功的结果时,它将返回一个授权令牌并持久化到local storge。

最后我们调用apiauthenticationstateprovider上的方法markuserasauthenticated ,设置httpclient的默认authorization header。

logout 这个方法就是执行与login 方法相反的操作。

注册组件(register component)

我们已经到了最后阶段了。现在我们可以将注意力转向ui,并创建一个允许人们在站点注册的组件。

@page "/register"
@inject iauthservice authservice
@inject navigationmanager navigationmanager

<h1>register</h1>

@if (showerrors) {
    <div class="alert alert-danger" role="alert">
        @foreach (var error in errors) {
            <p>@error</p>
        }
    </div>
}

<div class="card">
    <div class="card-body">
        <h5 class="card-title">please enter your details</h5>
        <editform model="registermodel" onvalidsubmit="handleregistration">
            <dataannotationsvalidator />
            <validationsummary />

            <div class="form-group">
                <label for="email">email address</label>
                <inputtext id="email" class="form-control" @bind-value="registermodel.email" />
                <validationmessage for="@(() => registermodel.email)" />
            </div>
            <div class="form-group">
                <label for="password">password</label>
                <inputtext id="password" type="password" class="form-control" @bind-value="registermodel.password" />
                <validationmessage for="@(() => registermodel.password)" />
            </div>
            <div class="form-group">
                <label for="confirmpassword">confirm password</label>
                <inputtext id="confirmpassword" type="password" class="form-control" @bind-value="registermodel.confirmpassword" />
                <validationmessage for="@(() => registermodel.confirmpassword)" />
            </div>
            <button type="submit" class="btn btn-primary">submit</button>
        </editform>
    </div>
</div>

@code {

    private registermodel registermodel = new registermodel();
    private bool showerrors;
    private ienumerable<string> errors;

    private async task handleregistration() {
        showerrors = false;

        var result = await authservice.register(registermodel);

        if (result.successful) {
            navigationmanager.navigateto("/login");
        } else {
            errors = result.errors;
            showerrors = true;
        }
    }

}

注册组件包含一个表单让用户输入他们的电子邮件和密码。提交表单时,会调用authservice 的方法register 。如果注册成功那么用户会被导航到登录页,否则,会将错误显示给用户。

登录组件(login component)

现在我们可以注册一个新的帐户,我们需要能够登录。登录组件将用于此。

@page "/login"
@inject iauthservice authservice
@inject navigationmanager navigationmanager

<h1>login</h1>

@if (showerrors) {
    <div class="alert alert-danger" role="alert">
        <p>@error</p>
    </div>
}

<div class="card">
    <div class="card-body">
        <h5 class="card-title">please enter your details</h5>
        <editform model="loginmodel" onvalidsubmit="handlelogin">
            <dataannotationsvalidator />
            <validationsummary />

            <div class="form-group">
                <label for="email">email address</label>
                <inputtext id="email" class="form-control" @bind-value="loginmodel.email" />
                <validationmessage for="@(() => loginmodel.email)" />
            </div>
            <div class="form-group">
                <label for="password">password</label>
                <inputtext id="password" type="password" class="form-control" @bind-value="loginmodel.password" />
                <validationmessage for="@(() => loginmodel.password)" />
            </div>
            <button type="submit" class="btn btn-primary">submit</button>
        </editform>
    </div>
</div>

@code {

    private loginmodel loginmodel = new loginmodel();
    private bool showerrors;
    private string error = "";

    private async task handlelogin() {
        showerrors = false;

        var result = await authservice.login(loginmodel);

        if (result.successful) {
            navigationmanager.navigateto("/");
        } else {
            error = result.error;
            showerrors = true;
        }
    }

}

与注册组件类似的设计,我们也提供一个表单用于用户输入电子邮件和密码。表单提交时,将调用authservice的方法login。如果登录成功,用户将被重定向到主页,否则将显示错误消息。

注销组件(logout component)

我们现在可以注册和登录,但我们也需要注销的功能。我用了一个页面组件来做这个,但是你也可以通过点击某个地方的按钮来实现。

@page "/logout"
@inject iauthservice authservice
@inject navigationmanager navigationmanager


@code {

    protected override async task oninitializedasync() {
        await authservice.logout();
        navigationmanager.navigateto("/");
    }

}

这个组件没有用户界面,当用户导航到它时,将调用authservice上的方法logout,然后将用户重定向回主页。

添加一个logindisplay组件并更新mainlayout组件

最后的任务是添加一个logindisplay组件并更新mainlayout 组件。

logindisplay 组件与blazor server模版一样,如果未经验证,它将显示登录与注册链接,否则显示电子邮件和注销链接。

<authorizeview>
    <authorized>
        hello, @context.user.identity.name!
        <a href="/logout">log out</a>
    </authorized>
    <notauthorized>
        <a href="/register">register</a>
        <a href="/login">log in</a>
    </notauthorized>
</authorizeview>

我们现在只需要更新mainlayout组件。

@inherits layoutcomponentbase

<div class="sidebar">
    <navmenu />
</div>

<div class="main">
    <div class="top-row px-4">
        <logindisplay />
        <a href="http://blazor.net" target="_blank" class="ml-md-auto">about</a>
    </div>

    <div class="content px-4">
        @body
    </div>
</div>

注册服务(registering services)

最后在startup类中注册服务。

            services.addblazoredlocalstorage();
            services.addauthorizationcore();
            services.addscoped<authenticationstateprovider, apiauthenticationstateprovider>();
            services.addscoped<iauthservice, authservice>();

如果一切都按计划进行,那么你应该得到这样的结果。

【翻译】使用WebApi和Asp.Net Core Identity 认证 Blazor WebAssembly(Blazor客户端应用)

总结

这篇文章展示了如何webapi和asp.net core identity创建一个带有身份验证的blazor webassembly(blazor客户端)应用程序。

展示webapi如何处理和签发令牌(json web tokens)。以及如何设置各种控制器操作来为客户端应用程序提供服务。最后,展示如何配置blazor来使用api和它签发的令牌来设置应用的身份验证状态。

最后也提供我学习本文跟随作者所写的源码(github)。