【问题标题】:Loopback authorize a user to see only his dataLoopback 授权用户只能查看他的数据
【发布时间】:2017-07-12 14:21:22
【问题描述】:

我正在使用 Loopback 开发一个 NodeJS 应用程序。

我对 nodejs 和 REST API 都很陌生,所以如果我在概念上是错误的,请纠正我。

Loopback 自动构建 CRUD REST API,这是我想使用的一个功能,以避免自己编写 API,但我需要限制用户只能看到他们的数据。

例如,假设我的数据库中有 3 个表,userbook 和一个关系表 user_book

例如:

table user
    id | name
    ---------
    1 | user1
    2 | user2
    3 | user3

table book
    id | title | author
    -------------------
    1 | title1 | author1
    2 | title2 | author1
    3 | title3 | author2
    4 | title4 | author2
    5 | title5 | author3

table user_book
    id | user_id | book_id
    -------------------
    1 |     1    |    1
    2 |     1    |    4
    3 |     1    |    3
    4 |     2    |    3
    5 |     2    |    2
    6 |     2    |    1
    7 |     3    |    3

当用户 X 被验证时,API /books 应该只回答 X 的书,而不是表中的每一本书。例如,如果用户user1 已登录并调用/books,则他应该只获取他的书籍,因此ID 为1, 3, 4 的书籍。

同样,/books?filter[where][book_author]='author1' 应该只返回作者为“author1”的用户X 的书籍。

我发现 loopback 提供remote hooks 在远程方法执行之前和之后附加,并且还提供所谓的scopes to

[...]指定可以作为方法调用引用的常用查询 在模型上[...]

我正在考虑使用 2 的组合来限制对表 books 的访问仅限于运行调用 API 的用户的行。

module.exports = function (book) {

  // before every operation on table book
  book.beforeRemote('**', function (ctx, user, next) {
    [HERE I WOULD PERFORM A QUERY TO FIND THE BOOKS ASSOCIATED WITH THE USER, LET'S CALL ID book_list]

    ctx._ds = book.defaultScope; // save the default scope
    book.defaultScope = function () {
      return {
        'where': {
          id in book_list
        }
      };
    };

    next();
  });

  book.afterRemote('**', function (ctx, user, next) {
    book.defaultScope = ctx._ds; // restore the default scope
    next();
  });
};

这个解决方案行得通吗?特别是,我特别关心并发性。如果来自不同用户对/books 的多个请求发生,那么更改默认范围是否是一项关键操作?

【问题讨论】:

    标签: node.js express loopbackjs


    【解决方案1】:

    我们实现这一点的方法是创建一个 mixin。看看 github 中的环回时间戳混合。我建议混合创建与您的用户模型的“所有者”关系。简而言之,它的工作原理如下:

    • 每个使用 mixin 的模型都会在模型和用户之间创建一个关系
    • 每次创建模型的新实例时,都会将 userId 与实例一起保存
    • 每次调用 findfindById 时,都会修改查询以添加 {where:{userId:[currently logged in user id]}} 子句

    /common/mixins/owner.js

    'use strict';
    module.exports = function(Model, options) {
      // get the user model
      var User = Model.getDataSource().models.User;
      // create relation to the User model and call it owner
      Model.belongsTo(User, {as: 'owner', foreignKey: 'ownerId'});
    
      // each time your model instance is saved, make sure the current user is set as the owner
      // need to do this for upsers too (code not here)
      Model.observe('before save', (ctx, next)=>{
        var instanceOrData = ctx.data ? 'data' : 'instance';
        ctx[instanceOrData].ownerId = ctx.options.accessToken.userId;
      });
    
      // each time your model is accessed, add a where-clause to filter by the current user
      Model.observe('access', (ctx, next)=>{
        const userId = safeGet(ctx, 'options.accessToken.userId');
        if (!userId) return next();  // no access token, internal or test request;
        var userIdClause = {userId: userId};
    
        // this part is tricky because you may need to add
        // the userId filter to an existing where-clause
    
        ctx.query = ctx.query || {};
        if (ctx.query.where) {
          if (ctx.query.where.and) {
            if (!ctx.query.where.and.some((andClause)=>{
              return andClause.hasOwnProperty('userId');
            })) {
              ctx.query.where.and.push(userIdClause);
            }
          } else {
            if (!ctx.query.where.userId) {
              var tmpWhere = ctx.query.where;
              ctx.query.where = {};
              ctx.query.where.and = [tmpWhere, userIdClause];
            }
          }
        } else {
          ctx.query.where = userIdClause;
        }
        next();
      });
    };
    

    /common/models/book.json

    {
      "mixins": {
        "Owner": true
      }
    }
    

    每次您使用 Owner 混合时,每次创建或保存新实例时,该模型都会自动添加并填充 ownerId 属性,并且每次“获取”数据时都会自动过滤结果。

    【讨论】:

    • 您的解决方案非常优雅!
    • 您的 model.observe 函数似乎缺少 next() 语句。至少在我的情况下。否则很好的解决方案!
    • 确实如此!很好的收获。
    • @YeeHaw1234 这看起来不安全。 API 调用者可以指定他们想要的任何 userId 过滤器,从而允许他们访问其他用户的数据。如果已有过滤器,则此代码不会添加新过滤器。 (此外,它不会在整个过滤器树中搜索嵌套和子句。)试试这个 ctx.query.where = ctx.query.where ? {and: [userIdClause, ctx.query.where]} : userIdClause;
    • @MikeFisher 我在最外面的 and 子句中添加了 userId 子句,所以我认为它可以处理任何嵌套的 or 子句:false AND (false OR true),所以即使用户滑入“其中 userId == = myId",最外面的 AND 将被应用并忽略任何试图绕过安全性的内部子句
    【解决方案2】:

    我认为解决方案是使用环回关系。您必须设置关系: - 用户通过用户书有很多书 - 书通过用户书有很多用户

    类似于loopback文档提供的这个例子:loopback docs

    假设用户应该在使用该功能之前进行身份验证,那么您可以通过 user/userId/books 来获取特定用户可以访问的书籍。

    如果你想限制访问,那么你应该使用 ACL。对于这种情况,您必须使用自定义角色解析器,loopback 提供了相同的示例:roleResolver

    如果您应用了此解析器,则用户只能访问属于他们的图书。

    希望对你有帮助

    【讨论】:

      【解决方案3】:

      这是我对您问题的解决方案:

      /common/models/user_book.json

      {
        "name": "user_book",
        "base": "PersistedModel",
        "idInjection": true,
        "properties": {
          "id": {
            "type": "number",
            "required": true
          },
          "user_id": {
            "type": "number",
            "required": true
          },
          "book_id": {
            "type": "number",
            "required": true
          }
        },
        "validations": [],
        "relations": {
          "user": {
            "type": "belongsTo",
            "model": "user",
            "foreignKey": "user_id"
          },
          "book": {
            "type": "belongsTo",
            "model": "book",
            "foreignKey": "book_id"
          }
        },
        "acls": [{
            "accessType": "*",
            "principalType": "ROLE",
            "principalId": "$authenticated",
            "permission": "ALLOW",
            "property": "*"
          }],
        "methods": []
      }
      

      /common/models/book

      {
        "name": "book",
        "base": "PersistedModel",
        "idInjection": true,
        "properties": {
          "id": {
            "type": "number",
            "required": true
          },
          "title": {
            "type": "string",
            "required": true
          },
          "author": {
            "type": "string",
            "required": true
          }
        },
        "validations": [],
        "relations": {
            "users": {
              "type": "hasMany",
              "model": "user",
              "foreignKey": "book_id",
              "through": "user_book"
            }
        },
        "acls": [{
            "accessType": "*",
            "principalType": "ROLE",
            "principalId": "$authenticated",
            "permission": "ALLOW",
            "property": "*"
          }],
        "methods": []
      }
      

      /common/models/user.json

      {
        "name": "user",
        "base": "User",
        "idInjection": true,
        "properties": {},
        "validations": [],
        "relations": {
          "projects": {
            "type": "hasMany",
            "model": "project",
            "foreignKey": "ownerId"
          },
          "teams": {
            "type": "hasMany",
            "model": "team",
            "foreignKey": "ownerId"
          },
          "books": {
            "type": "hasMany",
            "model": "book",
            "foreignKey": "user_id",
            "through": "user_book"
          }
        },
        "acls": [{
            "accessType": "*",
            "principalType": "ROLE",
            "principalId": "$everyone",
            "permission": "ALLOW",
            "property": "listMyBooks"
          }],
        "methods": []
      }
      

      然后在用户模型 js 文件中,您需要创建一个自定义的远程方法,使用 HTTP 动词“GET”并具有路由“/books”。在其处理函数中,您应该获取经过身份验证的用户实例(带有访问令牌信息)并仅返回 user.books(通过回送实现 through 关系)以获取 user_book 模型指定的相关书籍。下面是代码示例:

      /common/models/user.js

      module.exports = function(User) {
        User.listMyBooks = function(accessToken,cb) {
          User.findOne({where:{id:accessToken.userId}},function(err,user) {
            user.books(function (err,books){
                if (err) return cb(err);
                return cb(null,books);
            });
          });
        };
        User.remoteMethod('listMyBooks', {
          accepts: [{arg: 'accessToken', type: 'object', http: function(req){return req.res.req.accessToken}}],
          returns: {arg: 'books', type: 'array'},
          http: {path:'/books', verb: 'get'}
        });
      };
      

      还请确保远程方法公开以供公众访问:

      /server/model-config.json:

        ...
        "user": {
          "dataSource": "db",
          "public": true
        },
        "book": {
          "dataSource": "db",
          "public": true
        },
        "user_book": {
          "dataSource": "db",
          "public": true
        }
        ...
      

      有了这些,你应该可以打电话给GET /users/books?access_token=[authenticated token obtained from POST /users/login] 获取属于经过身份验证的用户的书籍列表。 有关在环回中使用 has-many-through 关系的参考资料:https://loopback.io/doc/en/lb3/HasManyThrough-relations.html

      祝你好运! :)

      【讨论】:

        【解决方案4】:

        我想补充一下 YeeHaw1234 的回答。我计划按照他描述的方式使用 Mixins,但我需要更多的字段来过滤数据,而不仅仅是用户 ID。我还有 3 个其他字段要添加到访问令牌中,这样我就可以在尽可能低的级别强制执行数据规则。

        我想在会话中添加一些字段,但不知道如何在 Loopback 中添加。我查看了 express-session 和 cookie-express,但问题是我不想重写 Loopback 登录,而 Login 似乎是应该设置会话字段的地方。

        我的解决方案是创建自定义用户和自定义访问令牌并添加我需要的字段。然后,我使用操作挂钩(保存之前)在写入新访问令牌之前插入我的新字段。

        现在每次有人登录时,我都会获得额外的字段。如果有更简单的方法可以将字段添加到会话中,请随时告诉我。我计划添加一个更新访问令牌,以便如果用户在登录时更改权限,他们将在会话中看到这些更改。

        这里是一些代码。

        /common/models/mr-access-token.js

        var app = require('../../server/server');
        
        module.exports = function(MrAccessToken) {
        
          MrAccessToken.observe('before save', function addUserData(ctx, next) {
            const MrUser = app.models.MrUser;
            if (ctx.instance) {
              MrUser.findById(ctx.instance.userId)
                .then(result => {
                ctx.instance.setAttribute("role");
                ctx.instance.setAttribute("teamId");
                ctx.instance.setAttribute("leagueId");
                ctx.instance.setAttribute("schoolId");
                ctx.instance.role = result.role;
                ctx.instance.teamId = result.teamId;
                ctx.instance.leagueId = result.leagueId;
                ctx.instance.schoolId = result.schoolId;
                next();
              })
              .catch(err => {
                console.log('Yikes!');
              })
            } else {
              MrUser.findById(ctx.instance.userId)
                .then(result => {
                ctx.data.setAttribute("role");
                ctx.data.setAttribute("teamId");
                ctx.data.setAttribute("leagueId");
                ctx.data.setAttribute("schoolId");
                ctx.data.role = result.role;
                ctx.data.teamId = result.teamId;
                ctx.data.leagueId = result.leagueId;
                ctx.data.schoolId = result.schoolId;
                next();
              })
              .catch(err => {
                console.log('Yikes!');
              })
            }
          })
        
        
        };
        

        这花了我很长时间来调试。这是我遇到的一些障碍。我最初认为它需要在 /server/boot 中,但我没有看到保存时触发的代码。当我将它移到 /common/models 时,它开始触发。试图弄清楚如何从观察者中引用第二个模型不在文档中。 var app = ... 在另一个 SO 答案中。最后一个大问题是我在异步 findById 之外有next(),因此实例被原样返回,然后异步代码将修改该值。

        /common/models/mr-user.js

        {
          "name": "MrUser",
          "base": "User",
          "options": {
            "idInjection": false,
            "mysql": {
              "schema": "matrally",
              "table": "MrUser"
            }
          },
          "properties": {
            "role": {
              "type": "String",
              "enum": ["TEAM-OWNER",
                "TEAM-ADMIN",
                "TEAM-MEMBER",
                "SCHOOL-OWNER",
                "SCHOOL-ADMIN",
                "SCHOOL-MEMBER",
                "LEAGUE-OWNER",
                "LEAGUE-ADMIN",
                "LEAGUE-MEMBER",
                "NONE"],
              "default": "NONE"
            }
          },
          "relations": {
            "accessTokens": {
              "type": "hasMany",
              "model": "MrAccessToken",
              "foreignKey": "userId",
              "options": {
                "disableInclude": true
              }
            },
            "league": {
              "model": "League",
              "type": "belongsTo"
            },
            "school": {
              "model": "School",
              "type": "belongsTo"
            },
            "team": {
              "model": "Team",
              "type": "belongsTo"
            }
          }
        }
        

        /common/models/mr-user.js

        {
          "name": "MrAccessToken",
          "base": "AccessToken",
          "options": {
            "idInjection": false,
            "mysql": {
              "schema": "matrally",
              "table": "MrAccessToken"
            }
          },
          "properties": {
            "role": {
              "type": "String"
            }
          },
          "relations": {
            "mrUser": {
              "model": "MrUser",
              "type": "belongsTo"
            },
            "league": {
              "model": "League",
              "type": "belongsTo"
            },
            "school": {
              "model": "School",
              "type": "belongsTo"
            },
            "team": {
              "model": "Team",
              "type": "belongsTo"
            }
          }
        }
        

        /server/boot/mrUserRemoteMethods.js

        var senderAddress = "curtis@abcxyz.com"; //Replace this address with your actual address
        var config = require('../../server/config.json');
        var path = require('path');
        
        
        module.exports = function(app) {
          const MrUser = app.models.MrUser;
        
        
          //send verification email after registration
          MrUser.afterRemote('create', function(context, user, next) {
            var options = {
              type: 'email',
              to: user.email,
              from: senderAddress,
              subject: 'Thanks for registering.',
              template: path.resolve(__dirname, '../../server/views/verify.ejs'),
              redirect: '/verified',
              user: user
            };
        
            user.verify(options, function(err, response) {
              if (err) {
                MrUser.deleteById(user.id);
                return next(err);
              }
              context.res.render('response', {
                title: 'Signed up successfully',
                content: 'Please check your email and click on the verification link ' +
                    'before logging in.',
                redirectTo: '/',
                redirectToLinkText: 'Log in'
              });
            });
          });
        
          // Method to render
          MrUser.afterRemote('prototype.verify', function(context, user, next) {
            context.res.render('response', {
              title: 'A Link to reverify your identity has been sent '+
                'to your email successfully',
              content: 'Please check your email and click on the verification link '+
                'before logging in',
              redirectTo: '/',
              redirectToLinkText: 'Log in'
            });
          });
        
          //send password reset link when requested
          MrUser.on('resetPasswordRequest', function(info) {
            var url = 'http://' + config.host + ':' + config.port + '/reset-password';
            var html = 'Click <a href="' + url + '?access_token=' +
                info.accessToken.id + '">here</a> to reset your password';
        
            MrUser.app.models.Email.send({
              to: info.email,
              from: senderAddress,
              subject: 'Password reset',
              html: html
            }, function(err) {
              if (err) return console.log('> error sending password reset email');
              console.log('> sending password reset email to:', info.email);
            });
          });
        
          //render UI page after password change
          MrUser.afterRemote('changePassword', function(context, user, next) {
            context.res.render('response', {
              title: 'Password changed successfully',
              content: 'Please login again with new password',
              redirectTo: '/',
              redirectToLinkText: 'Log in'
            });
          });
        
          //render UI page after password reset
          MrUser.afterRemote('setPassword', function(context, user, next) {
            context.res.render('response', {
              title: 'Password reset success',
              content: 'Your password has been reset successfully',
              redirectTo: '/',
              redirectToLinkText: 'Log in'
            });
          });
        
        };
        

        这直接来自示例,但不清楚是否应该在 /boot 中注册它。在将自定义用户从 /common/models 移动到 /server/boot 之前,我无法让自定义用户发送电子邮件。

        【讨论】:

          【解决方案5】:
          'use strict';
          module.exports = function(Model, options) {
            // get the user model
            var User = Model.getDataSource().models.User;
            var safeGet = require("l-safeget");
            // create relation to the User model and call it owner
            Model.belongsTo(User, {as: 'owner', foreignKey: 'ownerId'});
          
            // each time your model instance is saved, make sure the current user is set as the owner
            // need to do this for upsers too (code not here)
            Model.observe('before save', (ctx, next)=>{
              var instanceOrData = ctx.data ? 'data' : 'instance';
              ctx[instanceOrData].ownerId = ctx.options.accessToken.userId;
              next();
            });
          
          Model.observe('access', (ctx, next)=>{
              const userId = safeGet(ctx, 'options.accessToken.userId');
              if (!userId) return next();  // no access token, internal or test request;
              var userIdClause = {ownerId: userId};
          
              // this part is tricky because you may need to add
              // the userId filter to an existing where-clause
          
              ctx.query = ctx.query || {};
              if (ctx.query.where) {
                  if (!ctx.query.where.ownerId) {
                    var tmpWhere = ctx.query.where;
                    ctx.query.where = {};
                    ctx.query.where.and = [tmpWhere, userIdClause];
          
            }     }
               else {
                ctx.query.where = userIdClause;
          
              }
              next();
           });
          };
          

          使用这个 mixim 而不是 @YeeHaw1234 answer 。其他步骤都一样。

          【讨论】:

          • 什么是 l-safeget?我进行了一些搜索,但没有提出任何项目或库。谢谢
          • 我找到了。不知道为什么我不知道你可以在 npmjs.com 上搜索
          • 这看起来不安全。 API 调用者可以指定他们想要的任何 ownerId 过滤器,从而允许他们访问其他用户的数据。如果已有过滤器,则此代码不会添加新过滤器。
          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2017-03-15
          • 1970-01-01
          • 2020-10-09
          • 1970-01-01
          • 1970-01-01
          相关资源
          最近更新 更多