Restricted, login-free access for existing users

Hi everyone,

I’m hoping to get help with how to work with the Accounts packages and something like alanning:roles to allow users with existing accounts to perform certain actions without having to log in.

The use case is to send a link with a unique token (tied to an existing account) which grants access to perform some actions but requires full login for others. For example, let an existing user with the link see her latest in-app notifications but changing her email address would prompt for login.

If we can figure out a good design, I’d like to make it into a package that everyone can use.

I think we can figure out a way to do it that would avoid having to change existing auth code. One thing we’d definitely have to avoid is changing the security checks that are already in place just to support this feature.

Challenges:

One challenge is how to get the accounts packages to do this kind of pseudo-login. I’d suspect we’d have to bypass it a bit and write our own login token to the user account, for example. But also some kind of an indicator that access is restricted.

With roles, this may be saving the existing roles and restoring them later upon full login.

Another challenge is keeping the UI accurate even if roles are swapped out. For example, if a user has broadcast rights and that is something that requires a full login to do, the Broadcast nav button should still be visible to the user. So that would imply a special check somehow on “will have access after login”.

Looking for any help or thoughts on how to put this together!

1 Like

Maybe use https://github.com/artwells/meteor-accounts-guest?

Thanks for the link.

I’m guessing you are recommending copying the existing user’s account info to an anonymous account and then having checks on each action that would substitute the original user account’s _id when a request comes in?

Could be doable.

Ideally we’d be able to avoid changing any existing security checks. Would also have to figure out a way to handle fields that are restricted to be unique such as username or email addresses.

Using the user’s actual account but swapping out it’s existing roles may require less impact on the existing security checks.

I guess to really compare we’d need to outline the steps involved with an example use case. Sequence diagram would work great for this type of analysis.

This sounds like something that could be useful to us. Have you advanced with this at all @alanning ?

Currently we’re trying to figure out how to do the following:

  • Create a user in the system and associated data (but DON’T send enrolment)
  • Send user a special link where they can fill in a limited set of their data w/o logging in.
  • We finally grant the user access to the system and send them an enrolment email.

It might be simpler technically to enrole the user straight from that first email but we’d like to do it without login and access to the full site.

I would also like to be to able to let non-registered users modify data in some limited way but I’ll settle for existing ones for now :slightly_smiling:

I think Accounts already supports the behavior you are after. Its pretty much exactly what we do when a new user registers with us.

  1. They enter their email address
  2. We create a new user account (does not have password set) and send an email with link to complete enrollment. Link includes their enrollment token (just a random string).
  3. Users clicks link, client-side part makes a Meteor method call to verify the token.
  4. Once verified, display form (optionally include any data that was pre-set).
  5. Submit form and update user’s account.

Accounts won’t let them log in without a password so its an easy was to control access.

Tricky part is doing the server-side Accounts calls properly. Took some diving through accounts-password and accounts-base to get it right.

Right, that’s it almost exactly. I had just started on this actually and found myself re-doing some of these tasks.

So after I create the user I can grab his token under services.password.reset.token and send him a URL to go to a path '/update-account/:tokenId ’ or are you saying to use the standard URL generated by Enrolment and use their callback OnEnrolmentLink() ?

Once he clicks the link, how is the verification completed? If you could point me towards the correct API calls to make this happen that would be much appreciated !

Yes, we send users to “/enroll/:tokenId” then display the form with the fields we care about collecting.

Once they have filled out their profile info, you need to use the Accounts.callLoginMethod client-side call which handles logging them in if the process succeeds.

This should really be in some kind of tutorial or example app or something. Maybe @sashko would like it for the Meteor Guide.

Here’s how we do enrollment:

  // client-side call on enrollment form submit
  Accounts.callLoginMethod({
    methodName: 'enrollUser',
    methodArguments: [
      token,
      Accounts._hashPassword(formData.password),
      profileData
    ],
    userCallback: callback
  });

  // callback handles errors or re-directs to "/enrollment-complete" on success 

On the server-side, the enrollUser Meteor method looks like this:

  enrollUser: function (resetToken, newPassword, profileData) {
    var self = this
    return Accounts._loginMethod(
      self,
      "resetPassword",
      arguments,
      "password",
      function () {
        var user,
            email

        check(resetToken, String)
        check(newPassword, passwordValidator)

        user = Meteor.users.findOne({
                 "services.password.reset.token": resetToken })

        // validate your user and form data (throws if error)
        validators.enrollUser(user, newPassword, profileData)

        email = user.services.password.reset.email

        Accounts._setLoginToken(user._id, self.connection, null)

        MyApp.Users.enrollUser(user, newPassword, profileData)

        return {userId: user._id}
      }
    )  // end Accounts._loginMethod
  },  // end enrollUser

The enrollUser server-side function processes their enrollment data and fires off some other stuff which should happen when a new user enrolls but the important part for you is how it updates the user account. The following was compiled from the code from various accounts-password functions, like the reset password one:

  enrollUser: function (user, newPassword, profileData) {
    //... other stuff

    // NOTE: we don't replace login tokens anymore because it causes a
    // "You have been logged out..." error message to be displayed.
    // The login token will be set in the Meteor method using
    // Accounts._setLoginToken(...)

    // Update the user record by:
    // - Changing password to the new one
    // - Remove all login tokens. Changing password should invalidate
    //   existing sessions.  (loginTokens set via Accounts._loginMethod)
    // - Forgetting about the reset token that was just used
    // - Verifying their email, since they got the password reset via email
    // - Updating profile information
    update = {
      $set: {
        'services.password.bcrypt': hashPassword(newPassword),
        'services.resume.loginTokens': [],
        'emails.$.verified': true,
        'emails.$.verifiedAt': new Date(),
        'enrollToken': resetToken
      },
      $unset: {'services.password.reset': 1,
               'services.password.srp': 1}
    }

    try {
      Meteor.users.update({'emails.address': email}, update);

      // other stuff like, triggering reactions to new user enrolling...
 
      return user._id
    }
    catch (e) {
      handleEnrollmentError(e, tx)
    }
  },  // end enrollUser

Hope that helps!

3 Likes

Thanks @alanning, I really appreciate your help here. The Guide would definitely benefit from a more in-depth look at Accounts and non-standard behaviour.

I have one main difference to your approach though which is I don’t want the user to have set his password and be able login to the system subsequently. So maybe I’m going to have to do something without the accounts package.

I basically want to allow someone (maybe in Meteor.users but maybe not) to update a document via a URL which should be able to expire. I’m thinking a server-side collection with {docId, token, expiryDate}. When a request comes to update a doc it should come with this token. I can then lookup the doc to modify and check the token hasn’t expired and perform the update. It’s essentially a temporary URL to modify a doc.

Maybe if I explain another use case it’ll be clear. A user creates an Event (party / wedding etc). He wants to invite 200+ guests to the party but we don’t want them to be in Meteor.users but we do want them to have limited access to their guest-details-document and be able to update say their attending status or menu choices.

Am I creating a huge security hole here ? In theory they should only be able to modify quite limited data which is not sensitive.

If you have a time-out on the tokens and carefully manage what the user can do when using the token (and when not) then it’s not a security hole, just a known (and well understood) risk. Depending on your use case, you may also want to make the tokens “one-time-use-only” as well.

As for how to do it, it seems like you don’t even need to use Accounts for the token-based access part. Say we’re on the edit guest RSVP page, for example. You’ll want to show some existing data, so the authorization check when you retrieve the data has two parts: either someone with the access token or a regular user with the proper access. When performing the update, you’ll do the two-part check again. (Server-side is for security, client-side is for convenience.)

1 Like

Thanks for the tips! I’m going to give it a try today and see how it goes.

Just an update on this project for everyone, there’s now a working prototype that you can check out here:

Its still in development and Loren is currently working on integrating it into our existing application so there may still need to be some tweaks made but the basics are all there.

Loren went with a per-connection solution vs. actually changing the user’s database record since that made the design nicer and enables scenarios like admins managing user permissions without any weirdness.