Loopback autoriza a un usuario a ver solo sus datos

Estoy desarrollando una aplicación NodeJS usando Loopback.

Soy bastante nuevo en las API de nodo y REST, así que corríjame si me equivoco conceptualmente.

Loopback crea automáticamente las API REST de CRUD, que es una característica que me gustaría usar para evitar escribir API por mi cuenta, pero necesito limitar a los usuarios para que puedan ver solo sus datos.

Por ejemplo, imagine que hay 3 tablas en mi base de datos, user , book y una tabla de relación user_book .

Así por ejemplo:

 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 

Cuando se autentica un usuario X , la API /books deben responder con SOLO los libros de X, y no todos los libros de la tabla. Por ejemplo, si el usuario user1 está registrado y llama /books , solo debe obtener sus libros, así que los libros con ID 1, 3, 4 .

De forma similar, /books?filter[where][book_author]='author1' debería devolver solo los libros del usuario X cuyo autor es ‘author1’.

Descubrí que el loopback ofrece enlaces remotos para adjuntar antes y después de la ejecución de un método remoto, y también ofrece los llamados ámbitos para

[…] especifique consultas de uso común a las que puede hacer referencia como llamadas de método en un modelo […]

Estaba pensando en usar una combinación de los 2 para limitar el acceso a los books tablas a solo filas del usuario que ejecuta las llamadas 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; // restre the default scope next(); }); }; 

¿Funcionaría esta solución? En particular, estoy particularmente preocupado por la concurrencia. Si se realizan múltiples solicitudes para /books de diferentes usuarios, ¿cambiar el scope predeterminado sería una operación crítica?

La forma en que lo logramos fue crear una mezcla. Echa un vistazo a la marca de tiempo de loopback mezclando en github. Recomendaría la mezcla crear una relación de “propietario” para su modelo de usuario. Así es como funciona en pocas palabras:

  • Cada modelo que use la mezcla tendrá una relación creada entre el modelo y el usuario.
  • Cada vez que se crea una nueva instancia del modelo, el ID de usuario se guardará con la instancia
  • Cada vez que se llama a find o findById , la consulta se modificará para agregar la cláusula {donde: {userId: [identificada actualmente en id de usuario]}}

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

Cada vez que use la mezcla de Propietarios, ese modelo tendrá automáticamente una propiedad ownerId agregada y completada cada vez que se crea o guarda una nueva instancia, y los resultados se filtrarán automáticamente cada vez que “obtenga” los datos.

Creo que la solución está utilizando la relación de bucle invertido. Debe establecer la relación: – El usuario tiene muchos libros en el libro de usuarios – El libro tiene muchos usuarios en el libro de usuarios

Es similar a este ejemplo proporcionado por la documentación de loopback : docs de loopback

Entonces, digamos que el usuario debe ser autenticado antes de usar la función , luego puede pasar user / userId / books para que un usuario específico pueda acceder a los libros.

Si desea limitar el acceso, debe utilizar ACL. Para este caso, debe usar una resolución de roles personalizada, el mismo ejemplo lo proporciona loopback: roleResolver

Si aplicó esta resolución, el usuario solo puede acceder a los libros que les pertenecen.

Espero que esto ayude

Me gustaría añadir a la respuesta de YeeHaw1234. Planeo usar Mixins de la manera que él describe, pero necesitaba más campos que solo ID de usuario para filtrar los datos. Tengo otros 3 campos que quería agregar al token de acceso para poder aplicar las reglas de datos en el nivel más bajo posible.

Quería agregar algunos campos a la sesión, pero no pude averiguar cómo en Loopback. Miré Express-Session y Cookie-Express, pero el problema era que no quería volver a escribir el inicio de sesión de Loopback y el inicio de sesión parecía ser el lugar donde se deberían establecer los campos de la sesión.

Mi solución fue crear un usuario personalizado y un token de acceso personalizado y agregar los campos que necesitaba. Luego utilicé un enlace de operación (antes de guardar) para insertar mis nuevos campos antes de escribir un nuevo token de acceso.

Ahora, cada vez que alguien inicia sesión, obtengo mis campos adicionales. No dude en hacerme saber si hay una forma más fácil de agregar campos a una sesión. Planeo agregar un token de acceso de actualización para que, si los permisos del usuario cambian mientras están conectados, verán esos cambios en la sesión.

Aquí está algo del código.

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

Esto me tomó mucho tiempo para depurar. Aquí había algunos obstáculos que tenía. Inicialmente pensé que necesitaba estar en / server / boot, pero no estaba viendo el código activado en los guardados. Cuando lo moví a / common / models comenzó a disparar. Tratar de averiguar cómo hacer referencia a un segundo modelo desde el observador no estaba en los documentos. La var app = ... estaba en otra respuesta SO. El último gran problema fue que tuve el next() fuera del FindById de async, por lo que la instancia se devolvió sin cambios y, a continuación, el código async modificaría el valor.

/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 = "[email protected]"; //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 here 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' }); }); }; 

Esto es directamente de los ejemplos, pero no estaba claro si debería estar registrado en / boot. No pude hacer que mi usuario personalizado envíe correos electrónicos hasta que lo moví de / common / models a / server / boot.

Aquí está mi solución a su problema:

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

Luego, en el archivo js del modelo de usuario, debe crear un método remoto personalizado con el verbo HTTP “GET” y tiene la ruta “/ libros”. En su función de manejo, debe obtener la instancia de usuario autenticado (con la información del token de acceso) y simplemente devolver user.books (implementado por loopback para la relación a través) para obtener sus libros relacionados especificados por el modelo user_book. Aquí está el ejemplo del código:

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

Asegúrese también de que los métodos remotos estén expuestos para el acceso público:

/server/model-config.json:

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

Con estos, debería poder llamar a GET /users/books?access_token=[authenticated token obtained from POST /users/login] para obtener la lista de libros que pertenecen al usuario autenticado. Consulte las referencias para el uso de la relación has-many-through en loopback: https://loopback.io/doc/en/lb3/HasManyThrough-relations.html

¡Buena suerte! 🙂

 '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 esta respuesta en lugar de @ YeeHaw1234. Todos los demás pasos son iguales.