-->

Loopback authorize a user to see only his data

2020-07-24 06:54发布

问题:

I am developing a NodeJS application using Loopback.

I am pretty new to both nodejs and REST APIs, so please correct me if I am conceptually wrong.

Loopback automatically builds CRUD REST APIs, which is a feature I would like to use in order to avoid to write APIs by myself, but I need to limit users to be able to see only their data.

For example, imagine there are 3 tables in my DB, user, book and a relation table user_book.

So for example:

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

When a user X is authenticated, the API /books should answer with ONLY X's books, and not every book in the table. For example, if user user1 is logged and calls /books, he get should only get his books, so books with id 1, 3, 4.

Similarly, /books?filter[where][book_author]='author1' should return only books of user X whose author is 'author1'.

I found out that loopback offers remote hooks to attach before and after the execution of a remote method, and also offers so called scopes to

[...]specify commonly-used queries that you can reference as method calls on a model[...]

I was thinking about using a combination of the 2 in order to limit access to the table books to only rows of the user that runs calls the 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();
  });
};

Would this solution work? In particular, I am particularly concerned about concurrency. If multiple requests happen for /books from different users, would changing the default scope be a critical operation?

回答1:

The way we accomplished this was to create a mixin. Have a look at the loopback timestamp mixing in github. I would recommend the mixing create an "owner" relation to your user model. Here's how it works in a nutshell:

  • Each model that uses the mixin will have a relation created between the model and the user
  • Every time a new instance of the model is created, the userId will be saved with the instance
  • Everytime find or findById is called, the query will be amended to add the {where:{userId:[currently logged in user id]}} clause

/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
  }
}

Every time you use the Owner mixing, that model will automatically have a ownerId property added and filled each time a new instance is created or saved and the results will automatically be filtered each time you "get" the data.



回答2:

I think the solution is using loopback relation. You must set the relation: - User has many book through user book - Book has many user through user book

It is similar to this example provided by loopback documentation: loopback docs

So let's say user should be authenticated before using the function, then you can pass user/userId/books to get books accessible by specific user.

If you want to limit the access, then you should use ACL. For this case, you must use custom role resolver, the same example is provided by loopback: roleResolver

If you applied this resolver, then user can only access the books that belongs to them.

Hope this helps



回答3:

Here is my solution to your problem:

/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": []
}

Then in the user model js file, you need to create a customized remote method with HTTP verb "GET" and has route "/books". In its handling function, you should obtain the authenticated user instance (with the access token information) and just return user.books (implemented by loopback for the through relation) to obtain its related books specified by the user_book model. Here is the code example:

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

Please also make sure the remote methods are exposed for public access:

/server/model-config.json:

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

With these, you should be able to call GET /users/books?access_token=[authenticated token obtained from POST /users/login] to obtain the list of books belonging to the authenticated user. See references for the use of has-many-through relation in loopback: https://loopback.io/doc/en/lb3/HasManyThrough-relations.html

Good luck! :)



回答4:

I would like to add to YeeHaw1234's answer. I plan to use Mixins the way he describes, but I needed more fields than just User ID to filter the data. I have 3 other fields that I wanted to add to the Access token so I could enforce data rules at the lowest possible level.

I wanted to add some fields to the session, but couldn't figure out how in Loopback. I looked at express-session and cookie-express, but the problem was that I did not want to rewrite the Loopback login and Login seemed like the place where the session fields should be set.

My solution was to create a Custom User and Custom Access Token and add the fields I needed. I then used an operation hook (before save) to insert my new fields before a new Access Token was written.

Now every time someone logs in, I get my extra fields. Feel free to let me know if there is an easier way to add fields to a session. I plan to add an update Access Token so that if the user's permissions change while they are logged in that they will see those changes in the session.

Here is some of the code.

/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!');
      })
    }
  })


};

This took me a long time to debug. Here was a few hurdles I had. I initially thought it needed to be in /server/boot, but I wasn't seeing the code triggered on saves. When I moved it to /common/models it started firing. Trying to figure out how to reference a 2nd model from within the observer wasn't in the docs. The var app = ... was in another SO answer. The last big problem was I had the next() outside the async findById so the instance was being returned unchanged and then the async code would modify the value.

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

};

This is straight from the examples, but it wasn't clear that it should be registered in /boot. I couldn't get my custom user to send emails until I moved it from /common/models to /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();
 });
};

Use this mixim instead of @YeeHaw1234 answer . All other steps are same.