Authentication on Server side routes in Meteor
Asked Answered
A

5

10

What is the best way (most secure and easiest) to authenticate a user for a server side route?

Software/Versions

I'm using the latest Iron Router 1.* and Meteor 1.* and to begin, I'm just using accounts-password.

Reference code

I have a simple server side route that renders a pdf to the screen:

both/routes.js

Router.route('/pdf-server', function() {
  var filePath = process.env.PWD + "/server/.files/users/test.pdf";
  console.log(filePath);
  var fs = Npm.require('fs');
  var data = fs.readFileSync(filePath);
  this.response.write(data);
  this.response.end();
}, {where: 'server'});

As an example, I'd like to do something close to what this SO answer suggested:

On the server:

var Secrets = new Meteor.Collection("secrets"); 

Meteor.methods({
  getSecretKey: function () {
    if (!this.userId)
      // check if the user has privileges
      throw Meteor.Error(403);
    return Secrets.insert({_id: Random.id(), user: this.userId});
  },
});

And then in client code:

testController.events({
  'click button[name=get-pdf]': function () {
      Meteor.call("getSecretKey", function (error, response) {
        if (error) throw error;

        if (response) 
          Router.go('/pdf-server');
      });
  }
});

But even if I somehow got this method working, I'd still be vulnerable to users just putting in a URL like '/pdf-server' unless the route itself somehow checked the Secrets collection right?

In the Route, I could get the request, and somehow get the header information?

Router.route('/pdf-server', function() {
  var req = this.request;
  var res = this.response;
}, {where: 'server'});

And from the client pass a token over the HTTP header, and then in the route check if the token is good from the Collection?

Acoustician answered 1/1, 2015 at 19:54 Comment(0)
C
8

In addition to using url tokens as the other answer you could also use cookies:

Add in some packages that allow you to set cookies and read them server side:

meteor add mrt:cookies thepumpinglemma:cookies

Then you could have something that syncs the cookies up with your login status

Client Side

Tracker.autorun(function() {
     //Update the cookie whenever they log in or out
     Cookie.set("meteor_user_id", Meteor.userId());
     Cookie.set("meteor_token", localStorage.getItem("Meteor.loginToken"));
});

Server Side

On the server side you just need to check this cookie is valid (with iron router)

Router.route('/somepath/:fileid', function() {

   //Check the values in the cookies
   var cookies = new Cookies( this.request ),
       userId = cookies.get("meteor_user_id") || "",
       token = cookies.get("meteor_token") || "";

   //Check a valid user with this token exists
   var user = Meteor.users.findOne({
       _id: userId,
       'services.resume.loginTokens.hashedToken' : Accounts._hashLoginToken(token)
   });

   //If they're not logged in tell them
   if(!user) return this.response.end("Not allowed");

   //Theyre logged in!
   this.response.end("You're logged in!");

}, {where:'server'});
Cleon answered 2/1, 2015 at 12:31 Comment(8)
Is there anyway to make the cookies temporary (for added security)? For example, a 30 min window in which the cookie is valid, after that the client will need to get a new cookie in order to access the file?Acoustician
@Acoustician they contain the same data as would be visible on the DDP wire, so they're as secure as the login token (which is hashed in db). Nonetheless the Cookies.set takes an optional third parameter which can contain an expires key, Cookies.set(key, val, { domain: 'example.com', path: '/', expires: new Date(new Date().getTime() + 36000000) }) would set it to expire in 10 hours for example.com for the path /Cleon
I tried out this code in the cookies.request there is no "meteor_user_id" or "meteor_token". I'll post a screenshot in my question since I can't post it here.Acoustician
@Acoustician if you do console.log(cookies) it comes out empty, you have to use .get. You might want to check the Chrome dev console to see the headers sent to the page if the cookie is included in thereCleon
@Acoustician this is correct as shown in your dev console screenshot, you need to use .get as the cookie parser is 'lazy', it doesn't read the cookies until you tell it to.Cleon
@Acoustician sorry there was a typo in my code. Use Cookie.set instead of Cookies.set, sorry! Fixed it now - stackoverflow.com/posts/27742272/revisionsCleon
This method works great! Thanks! One more thing, you said the cookies contain the same data as would be visible on the DDP wire, so there's no advantage to having a cookie expire in this case?Acoustician
@Acoustician No real advantage if you use the path:'/' to match the route. Typically the weakness would be csrf (e.g if you host on x.meteor.com which meteor.com can read, then so could y.meteor.com) - subdomains and cookies are a problem when meteor allows free hosting on subdomains, but if you set the domain properly this is not an issue.Cleon
F
6

I think I have a secure and easy solution for doing this from within IronRouter.route(). The request must be made with a valid user ID and auth token in the header. I call this function from within Router.route(), which then gives me access to this.user, or responds with a 401 if the authentication fails:

//  Verify the request is being made by an actively logged in user
//  @context: IronRouter.Router.route()
authenticate = ->
  // Get the auth info from header
  userId = this.request.headers['x-user-id']
  loginToken = this.request.headers['x-auth-token']

// Get the user from the database
if userId and loginToken
  user = Meteor.users.findOne {'_id': userId, 'services.resume.loginTokens.token': loginToken}

// Return an error if the login token does not match any belonging to the user
if not user
  respond.call this, {success: false, message: "You must be logged in to do this."}, 401

// Attach the user to the context so they can be accessed at this.user within route
this.user = user


//  Respond to an HTTP request
//  @context: IronRouter.Router.route()
respond = (body, statusCode=200, headers) ->
  this.response.statusCode statusCode
  this.response.setHeader 'Content-Type', 'text/json'
  this.response.writeHead statusCode, headers
  this.response.write JSON.stringify(body)
  this.response.end()

And something like this from the client:

Meteor.startup ->

  HTTP.get "http://yoursite.com/pdf-server",
    headers:
      'X-Auth-Token': Accounts._storedLoginToken()
      'X-User-Id': Meteor.userId()
    (error, result) ->  // This callback triggered once http response received         
      console.log result

This code was heavily inspired by RestStop and RestStop2. It's part of a meteor package for writing REST APIs in Meteor 0.9.0+ (built on top of Iron Router). You can check out the complete source code here:

https://github.com/krose72205/meteor-restivus

Flagella answered 7/1, 2015 at 23:10 Comment(3)
Thanks for the great answer! What would the client calling this code look like? Also, if I'm logged in, and take a server side route to render the pdf, like say, /pdf-server, would I be passing in my loginToken in the header automatically or is there something special that should be done to get the token in the header on the client side?Acoustician
@Aaron: I just updated the example with some client-side code for adding the auth headers. I also edited the response from the server to include a header you'll need server-side for CORS compliance (to accept requests from the browser). I will update the Restivus package to include that as well. I'm not sure if Meteor would automatically add anything to the header. I searched around but couldn't find anything about it. Sorry.Flagella
Sorry, you don't need to worry about the CORS compliance since you're making the request at the same domain. I'll remove that from the example to prevent any confusion.Flagella
S
5

Because server-side routes act as simple REST endpoints, they don't have access to user authentication data (e.g. they can't call Meteor.user()). Therefore you need to devise an alternative authentication scheme. The most straightforward way to accomplish this is with some form of key exchange as discussed here and here.

Example implementation:

server/app.js

// whenever the user logs in, update her apiKey
Accounts.onLogin(function(info) {
  // generate a new apiKey
  var apiKey = Random.id();
  // add the apiKey to the user's document
  Meteor.users.update(info.user._id, {$set: {apiKey: apiKey}});
});

// auto-publish the current user's apiKey
Meteor.publish(null, function() {
  return Meteor.users.find(this.userId, {fields: {apiKey: 1}});
});

lib/routes.js

// example route using the apiKey
Router.route('/secret/:apiKey', {name: 'secret', where: 'server'})
  .get(function() {
    // fetch the user with this key
    // note you may want to add an index on apiKey so this is fast
    var user = Meteor.users.findOne({apiKey: this.params.apiKey});

    if (user) {
      // we have authenticated the user - do something useful here
      this.response.statusCode = 200;
      return this.response.end('ok');
    } else {
      // the key is invalid or not provided so return an error
      this.response.statusCode = 403;
      return this.response.end('not allowed');
    }
  });

client/app.html

<template name="myTemplate">
    {{#with currentUser}}
      <a href="{{pathFor route='secret'}}">secret</a>
    {{/with}}
</template>

Notes

  • Make /secret only accessible via HTTPS.

  • While it's very likely that the user requesting /secret is currently connected, there is no guarantee that she is. The user could have logged in, copied her key, closed the tab, and initiated the request sometime later.

  • This is a simple means of user authentication. I would explore more sophisticated mechanisms (see the links above) if the server-route reveals high-value data (SSNs, credit cards, etc.).

  • See this question for more details on sending static content from the server.

Squires answered 1/1, 2015 at 22:57 Comment(1)
The only problem with this solution is that you query the database every time.Soonsooner
B
1

I truly believe using HTTP headers are the best solution to this problem because they're simple and don't require messing about with cookies or developing a new authentication scheme.

I loved @kahmali's answer, so I wrote it to work with WebApp and a simple XMLHttpRequest. This has been tested on Meteor 1.6.

Client

import { Meteor } from 'meteor/meteor';
import { Accounts } from 'meteor/accounts-base';

// Skipping ahead to the upload logic
const xhr = new XMLHttpRequest();
const form = new FormData();

// Add files
files.forEach((file) => {
  form.append(file.name,
    // So BusBoy sees as file instead of field, use Blob
    new Blob([file.data], { type: 'text/plain' })); // w/e your mime type is
});

// XHR progress, load, error, and readystatechange event listeners here

// Open Connection
xhr.open('POST', '/path/to/upload', true);

// Meteor authentication details (must happen *after* xhr.open)
xhr.setRequestHeader('X-Auth-Token', Accounts._storedLoginToken());
xhr.setRequestHeader('X-User-Id', Meteor.userId());

// Send
xhr.send(form);

Server

import { Meteor } from 'meteor/meteor';
import { WebApp } from 'meteor/webapp';
import { Roles } from 'meteor/alanning:roles'; // optional
const BusBoy = require('connect-busboy');
const crypto = require('crypto'); // built-in Node library

WebApp.connectHandlers
  .use(BusBoy())
  .use('/path/to/upload', (req, res) => {
    const user = req.headers['x-user-id'];
    // We have to get a base64 digest of the sha256 hashed login token
    // I'm not sure when Meteor changed to hashed tokens, but this is
    // one of the major differences from @kahmali's answer
    const hash = crypto.createHash('sha256');
    hash.update(req.headers['x-auth-token']);

    // Authentication (is user logged-in)
    if (!Meteor.users.findOne({
      _id: user,
      'services.resume.loginTokens.hashedToken': hash.digest('base64'),
    })) {
      // User not logged in; 401 Unauthorized
      res.writeHead(401);
      res.end();
      return;
    }

    // Authorization
    if (!Roles.userIsInRole(user, 'whatever')) {
      // User is not authorized; 403 Forbidden
      res.writeHead(403);
      res.end();
      return;
    }

    if (req.busboy) {
      // Handle file upload
      res.writeHead(201); // eventually
      res.end();
    } else {
      // Something went wrong
      res.writeHead(500); // server error
      res.end();
    }
  });

I hope this helps someone!

Behlke answered 16/11, 2017 at 15:41 Comment(0)
B
0

Since Meteor doesn't use session cookies, client must explicitly include some sort of user identification when making a HTTP request to a server route.

The easiest way to do it is to pass userId in the query string of the URL. Obviously, you also need to add a security token that will prove that the user is really who the claim they are. Obtaining this token can be done via a Meteor method.

Meteor by itself doesn't provide such mechanism, so you need some custom implementation. I wrote a Meteor package called mhagmajer:server-route which was thoroughly tested. You can learn more about it here: https://blog.hagmajer.com/server-side-routing-with-authentication-in-meteor-6625ed832a94

Brena answered 24/5, 2017 at 14:30 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.