Loopback authorize a user to see only his data

Viewed 2117

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?

5 Answers

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.

'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.

Related