原文地址:https://chrissainty.com/securing-your-blazor-apps-configuring-role-based-authorization-with-client-side-blazor/

什么是基于角色的授权?

 当涉及asp.net core授权时,我们有两种选择,基于角色和基于策略(也有基于声明的,但那只是基于策略的一种特殊类型)。

基于角色的授权最初是在asp.net(asp.net core之前)中引入,这是一种限制对资源访问的声明性方法。

开发人员可以指定用户必须是其成员的特定角色的名称,以便访问特定的资源。一般是使用[authorize]属性指定一个角色或角色列表[authorize(roles="admin")]。用户可以是单个角色的成员,也可以是多个角色的成员。

如何创建和管理角色取决于所使用的备份存储。到目前为止我们一直使用asp.net core identity,我们将继续使用它来管理和存储我们的角色。

本文章代码将基于前一篇基础上搭建。

设置asp.net core identity角色

我们需要添加角色服务到我们的应用中。我们需要更新startup类中的configureservice方法。

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

identityrole是asp.net core identity提供的默认角色类型。如果它无法满足你的需求,你可以提供其他的角色类型。

接下来我们将为数据库添加一些角色数据-添加一个用户和管理员角色。为此,我们将重载applicationdbcontext中的方法onmodelcreating

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

        protected override void onmodelcreating(modelbuilder builder) {
            base.onmodelcreating(builder);

            builder.entity<identityrole>().hasdata(new identityrole { name = "user", normalizedname = "user", id = guid.newguid().tostring(), concurrencystamp = guid.newguid().tostring() });
            builder.entity<identityrole>().hasdata(new identityrole { name = "admin", normalizedname = "admin", id = guid.newguid().tostring(), concurrencystamp = guid.newguid().tostring() });
        }
    }

完成之后,我们需要生成迁移,然后将其应用到数据库。

    add-migration seedroles
    update-database

为角色分配用户

现在我们已经有一些可用的角色了,我们现在来更新账户控制器(accounts controller)创建用户的动作。

在新增用户时候为其分配user角色。如果新用户的电子邮件以admin开头,则为其分配useradmin角色组。

 

        [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 });

            }

            //为所有的新用户分配user角色
            await _usermanager.addtoroleasync(newuser, "user");

            //如果电子邮件以'admin'开头则分配admin角色
            if (newuser.email.startswith("admin")) {
                await _usermanager.addtoroleasync(newuser, "admin");
            }

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

现在我们在用户注册时为其分配了角色,但我们需要将这些信息传递给blazor。我们需要更新json web token中的声明来处理这个需求。

将角色声明添加到jwt

现在我们来更新登录控制器(login controller)中的login方法。先以下用于生成声明的代码。

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

并使用以下代码替换。

            var user = await _signinmanager.usermanager.findbyemailasync(login.email);
            var roles = await _signinmanager.usermanager.getrolesasync(user);

            var claims= new list<claim>();

            claims.add(new claim(claimtypes.name, login.email));

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

我们通过usermanager获取当前用户并获取用户拥有的角色。之前是将用户电子邮件添加到name声明,现在如果用户拥有角色,我们则循环将角色添加到role声明中。

关于角色声明有一点比较很重要问题。如果一个用户拥有两个角色,那么这两个角色声明会被添加到jwt中。

http://schemas.microsoft.com/ws/2008/06/identity/claims/role - "user"
http://schemas.microsoft.com/ws/2008/06/identity/claims/role - "admin"

然后事实上并非如此,而是两个角色合并为一个数组。

http://schemas.microsoft.com/ws/2008/06/identity/claims/role - ["user", "admin"]

关于这一点很重要,在blazor客户端处理角色时需要注意。

在blazor客户端使用角色

我们将角色分配给新用户,当他们登录时,我们通过jwt返回这些角色。那么在blazor内部要如何使用角色呢?

在这个问题上目前微软官方并未提供任何可以帮助我们处理角色的东西,所以我们必须手动处理它。

 

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);
        }

上面代码对jwt进行解码、提取声明并返回声明。但我们没有涉及的是我对其进行了修改,以处理特殊情况下的角色。

如果存在角色声明,那么我们将检查第一个字符是否为[,表名它是一个json数组。如果找到roles声明,则解析提取角色名称,循环遍历角色名称,并将每个角色名称作为声明添加。如果roles不是一个数组,则作为单个角色声明添加。

这个方法不一定是最好的,但它确实实现了我们的目的。

我们需要更新markuserasauthenticated方法来调用parseclaimsfromjwt

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

            notifyauthenticationstatechanged(authstate);
        }

最后,我们需要更新authservice中的login方法,以便在调用markuserasauthenticated时传递令牌而不是电子邮件。

        public async task<loginresult> login(loginmodel loginmodel) {
            var result = await _httpclient.postjsonasync<loginresult>("api/login", loginmodel);

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

                return result;
            }

            return result;
        }

现在,我们应该能够将基于角色的授权应用到我们的应用程序中。我们来关注下api的处理。

将基于角色的授权应用于api

weatherforecastcontroller上的get方法设置为仅对admin角色中经过身份验证的用户可访问。我们使用authorize属性并指定用于访问它的角色。(这里在默认生成模版与原文有出入)

        [httpget]
        [authorize(roles = "admin")]
        public ienumerable<weatherforecast> get()
        {
            var rng = new random();
            return enumerable.range(1, 5).select(index => new weatherforecast
            {
                date = datetime.now.adddays(index),
                temperaturec = rng.next(-20, 55),
                summary = summaries[rng.next(summaries.length)]
            })
            .toarray();
        }

如果您创建一个属于admin角色的新用户,并在blazor应用程序中访问fetch data页面,您应该可以看到一切都按预期的加载。

但你如果创建一个普通的用户并执行相同的操作,您应该会看到页面被卡在loading...

 

在blazor中使用基于角色的授权

blazor还可以使用authorize属性来保护页面。这是通过使用@attribute指令来应该[authorize]属性来实现的。您还可以使用authorizeview组件来限制对页面部分的访问。

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

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

由于预测数据只对管理员用户可用,所以我们使用authorize属性限制对该页面的访问。

@page "/fetchdata"
@attribute [authorize(roles = "admin")]

现在尝试使用管理用户登录到该页面。一切应该都正常加载。然后尝试使用普通用户登录,您应该会看到一条未经授权的消息。

我们来测试一下authorizeview,在主页(index.razor)添加如下代码。

<authorizeview roles="user">
    <p>you can only see this if you're in the user role.</p>
</authorizeview>

<authorizeview roles="admin">
    <p>you can only see this if you're in the admin role.</p>
</authorizeview>

同样,使用管理员用户账户登录。您应该看到这两条消息,因为您同时拥有这两个角色权限。

如果您使用普通用户登录则只能看到第一条消息。

总结

在这篇文章中,我们了解了什么是基于角色的授权以及如何使用asp.net core identity来设置和管理角色。然后我们讨论了如何使用json web tokens将角色从api传递给客户端并处理在blazor中的角色声明,最后在api和blazor上实现一些基于角色的授权检查。

我只是想重申一下,您不能仅仅依赖于客户端身份验证或授权,客户端永远不能被信任。必须始终在服务器上执行身份验证和授权检查。

 

附上代码(github)