How to add External Service logins to an already existing account in Meteor? - meteor

Having created a profile page for my app, I would like to display a list of social services that the user is on. It struck me that the easiest way would be to use Meteor's built in accounts system for this.
Is there a good way to add external services to an existing account?
Also, will the user then be able to log in with either (e.g.) Facebook and his password from my app?
Another question that naturally follows: Is there a good way to add an application specific password to an account that was created with an external service?

Here's an alternate method. In this solution, I'm overriding a core function and adding some custom behavior. My goal is to associate the service data with the currently logged in user, then allow the core function to do its thing like normal.
orig_updateOrCreateUserFromExternalService = Accounts.updateOrCreateUserFromExternalService;
Accounts.updateOrCreateUserFromExternalService = function(serviceName, serviceData, options) {
var loggedInUser = Meteor.user();
if(loggedInUser && typeof(loggedInUser.services[serviceName]) === "undefined") {
var setAttr = {};
setAttr["services." + serviceName] = serviceData;
Meteor.users.update(loggedInUser._id, {$set: setAttr});
}
return orig_updateOrCreateUserFromExternalService.apply(this, arguments);
}
Pros:
Avoids creation of unnecessary accounts
Code is short and easy to understand
Code is easy to remove if this functionality is added to Meteor core
Cons:
Requires the user to be logged in. If a user logs in with twitter initially, logs out, and then logs in with facebook, then two seperate accounts will be created.
Users who share a computer may get their accounts merged unintentionally.
Relies on knowledge of how updateOrCreateUserFromExternalService works. This isn't terrible - because it's part of Meteor's public api it probably won't change drastically (not often anyway). But it's still risky.

Here is how I add credentials to existing user account: .../meteor-how-to-login-with-github-account.html

Yes, a user account can be associated with multiple services and have a password-based login at the same time. In the Meteor docs, you can see the structure of such a user account:
{
_id: "bbca5d6a-2156-41c4-89da-0329e8c99a4f", // Meteor.userId()
username: "cool_kid_13", // unique name
emails: [
// each email address can only belong to one user.
{ address: "cool#example.com", verified: true },
{ address: "another#different.com", verified: false }
],
createdAt: 1349761684042,
profile: {
// The profile is writable by the user by default.
name: "Joe Schmoe"
},
services: {
facebook: {
id: "709050", // facebook id
accessToken: "AAACCgdX7G2...AbV9AZDZD"
},
resume: {
loginTokens: [
{ token: "97e8c205-c7e4-47c9-9bea-8e2ccc0694cd",
when: 1349761684048 }
]
}
}
}
For adding a username/password login to an existing account, you can use Accounts.sendResetPasswordEmail on the server side. This also ensures the change happens authenticated and authorized.
Of course you can also just update the user record on the server side with a new password yourself, but this might create a security hole in your app. I would also advise against implementing your own crypto protocol for this if possible, as it is hard.
If you want to add other services than email, you could for example
call a server method that saves a random, long token in the current user's MongoDB document and returns it to the client.
re-login the user with another service using Accounts.loginWith[OtherService]. This logs the user out and in again, using a new account on the other service.
call a second server method with the returned token from the first method as parameter. This second method searches for the user account with the given token and merges its data into the current (new) account.

Check out the example and answer in this posting. It pretty much gives you the code to integrate multiple external and internal accounts. With minor tweaks, you can add the password fields for each account as you desire.
How to use Meteor.loginWithGoogle with mrt:accounts-ui-bootstrap-dropdown
Code:
isProdEnv = function () {
if (process.env.ROOT_URL == "http://localhost:3000") {
return false;
} else {
return true;
}
}
Accounts.loginServiceConfiguration.remove({
service: 'google'
});
Accounts.loginServiceConfiguration.remove({
service: 'facebook'
});
Accounts.loginServiceConfiguration.remove({
service: 'twitter'
});
Accounts.loginServiceConfiguration.remove({
service: 'github'
});
if (isProdEnv()) {
Accounts.loginServiceConfiguration.insert({
service: 'github',
clientId: '00000',
secret: '00000'
});
Accounts.loginServiceConfiguration.insert({
service: 'twitter',
consumerKey: '00000',
secret: '00000'
});
Accounts.loginServiceConfiguration.insert({
service: 'google',
appId: '00000',
secret: '00000'
});
Accounts.loginServiceConfiguration.insert({
service: 'facebook',
appId: '00000',
secret: '00000'
});
} else {
// dev environment
Accounts.loginServiceConfiguration.insert({
service: 'github',
clientId: '11111',
secret: '11111'
});
Accounts.loginServiceConfiguration.insert({
service: 'twitter',
consumerKey: '11111',
secret: '11111'
});
Accounts.loginServiceConfiguration.insert({
service: 'google',
clientId: '11111',
secret: '11111'
});
Accounts.loginServiceConfiguration.insert({
service: 'facebook',
appId: '11111',
secret: '11111'
});
}
Accounts.onCreateUser(function (options, user) {
if (user.services) {
if (options.profile) {
user.profile = options.profile
}
var service = _.keys(user.services)[0];
var email = user.services[service].email;
if (!email) {
if (user.emails) {
email = user.emails.address;
}
}
if (!email) {
email = options.email;
}
if (!email) {
// if email is not set, there is no way to link it with other accounts
return user;
}
// see if any existing user has this email address, otherwise create new
var existingUser = Meteor.users.findOne({'emails.address': email});
if (!existingUser) {
// check for email also in other services
var existingGitHubUser = Meteor.users.findOne({'services.github.email': email});
var existingGoogleUser = Meteor.users.findOne({'services.google.email': email});
var existingTwitterUser = Meteor.users.findOne({'services.twitter.email': email});
var existingFacebookUser = Meteor.users.findOne({'services.facebook.email': email});
var doesntExist = !existingGitHubUser && !existingGoogleUser && !existingTwitterUser && !existingFacebookUser;
if (doesntExist) {
// return the user as it came, because there he doesn't exist in the DB yet
return user;
} else {
existingUser = existingGitHubUser || existingGoogleUser || existingTwitterUser || existingFacebookUser;
if (existingUser) {
if (user.emails) {
// user is signing in by email, we need to set it to the existing user
existingUser.emails = user.emails;
}
}
}
}
// precaution, these will exist from accounts-password if used
if (!existingUser.services) {
existingUser.services = { resume: { loginTokens: [] }};
}
// copy accross new service info
existingUser.services[service] = user.services[service];
existingUser.services.resume.loginTokens.push(
user.services.resume.loginTokens[0]
);
// even worse hackery
Meteor.users.remove({_id: existingUser._id}); // remove existing record
return existingUser; // record is re-inserted
}
});

Related

How to populate client-side Meteor.user.services after OAuth with built-in accounts-ui package in Meteor v1.4+?

I'm using accounts-ui and accounts-google in Meteor v1.4.1. I can't get the user.services object to appear scoped in the client code. In particular, I need google's profile picture.
I've configured the server-side code to authenticate with Google like so:
import { Meteor } from 'meteor/meteor';
import { ServiceConfiguration } from 'meteor/service-configuration';
const services = Meteor.settings.private.oauth;
for (let service of Object.keys(services)) {
ServiceConfiguration.configurations.upsert({
service
}, {
$set: {
clientId: services[service].app_id,
secret: services[service].secret,
loginStyle: "popup"
}
});
}
...and the client side code to configure permissions like so:
Accounts.ui.config({
requestPermissions: {
google: ['email', 'profile']
},
forceApprovalPrompt: {
google: true
},
passwordSignupFields: 'EMAIL_ONLY'
});
When users click the 'Sign-In with Google' button, a pop-up appears and they can authenticate. No prompt appears, however, despite forceApprovalPrompt being set to true for google.
The big issue is that when I execute this,
const user = Meteor.user();
console.log(user.services);
anywhere in client code, I do not see the expected user services information. I check my database and it is definitely there for the taking:
$ mongo localhost:27017
> db.users.find({})
> ... "services" : { "google" : { "accessToken" : ... } } ...
I'm curious what I'm missing? Should I explicitly define a publish function in order for user services data to exist in the client?
The services property is intentionally hidden on the client side for security reasons. There are a couple of approaches here :
Suggestions
My preferred one would be to expose a meteor method to bring you the
public keys and avatars you might need in the few places you'd need
them.
On a successful login, you could record the data you need somewhere in the user object, but outside of the services property.
As you said, you could make a new publication which explicitly specifies which fields to retrieve and which ones to hide. You have to be careful what you publish, though.
Code Examples
Meteor methods:
// server
Meteor.methods({
getProfilePicture() {
const services = Meteor.user().services;
// replace with actual profile picture property
return services.google && services.google.profilePicture;
}
});
// client
Meteor.call('getProfilePicture', (err, profilePicture) => {
console.log('profile picture url', profilePicture);
});
Update on successful user creation (you might want to have a login hook as well to reflect any avatar/picture changes in google):
// Configure what happens with profile data on user creation
Accounts.onCreateUser((options, user) => {
if (!('profile' in options)) { options.profile = {}; }
if (!('providers' in options.profile)) { options.profile.providers = {}; }
// Define additional specific profile options here
if (user.services.google) {
options.profile.providers.google = {
picture: user.services.google.picture
}
}
user.profile = options.profile;
return user;
});
Publish only select data...
// Server
Meteor.publish('userData', function () {
if (this.userId) {
return Meteor.users.find({ _id: this.userId }, {
fields: { other: 1, things: 1 }
});
} else {
this.ready();
}
});
// Client
Meteor.subscribe('userData');

Add accountStatus to new users on app startup

I'm trying to add accountStatus to the users I create when I first run the application however it keeps crashing. accountStatus is not part of user.profile.
Can someone please look at my code and tell me what I'm doing wrong.
Thanks for any help.
Path: server.js
// run at Meteor app startup
Meteor.startup(function(options, user) {
// if users database is empty, seed these values
if(Meteor.users.find().count() < 1) {
// users array
var users = [
{firstName: 'Sam', lastName: 'Smith', email: 'sam#gmail.com', roles: ['is_student']},
];
// user creation
_.each(users, function(userData) {
// return id for use in roles assignment below
var userId = Accounts.createUser({
email: userData.email,
password: 'password',
profile: {
firstName: userData.firstName,
lastName: userData.lastName,
}
});
// verify user email
Meteor.users.update({ _id: userId }, { $set: { 'emails.0.verified': true } });
// add roles to user
Roles.addUsersToRoles(userId, userData.roles);
// add accountStatus and set to true
_.extend(userId, { accountStatus: true });
});
console.log('New users created!');
}
});
Look at this line:
_.extend(userId, { accountStatus: true });
And look at _.extend definition:
Copy all of the properties in the source objects over to the destination object, and return the destination object. It's in-order, so the last source will override properties of the same name in previous arguments.
What this line is supposed to do?

How to get user ID during user creation in Meteor?

I am creating default users on the server with a Meteor startup function. I want to create a user and also verify his/her email on startup (I'm assuming you can only do this after creating the account).
Here's what I have:
Meteor.startup(function() {
// Creates default accounts if there no user accounts
if(!Meteor.users.find().count()) {
// Set default account details here
var barry = {
username: 'barrydoyle18',
password: '123456',
email: 'myemail#gmail.com',
profile: {
firstName: 'Barry',
lastName: 'Doyle'
},
roles: ['webmaster', 'admin']
};
// Create default account details here
Accounts.createUser(barry);
Meteor.users.update(<user Id goes here>, {$set: {"emails.0.verified": true}});
}
});
As I said, I assume the user has to be created first before setting the the verified flag as true (if this statement is false please show a solution to making the flag true in the creation of the user).
In order to set the email verified flag to be true I know I can update the user after creation using Meteor.users.update(userId, {$set: {"emails.0.verified": true}});.
My problem is, I don't know how to get the userID of my newly created user, how do I do that?
You should be able to access the user id that is returned from the Accounts.createUser() function:
var userId = Accounts.createUser(barry);
Meteor.users.update(userId, {
$set: { "emails.0.verified": true}
});
Alternatively you can access newly created users via the Accounts.onCreateUser() function:
var barry = {
username: 'barrydoyle18',
password: '123456',
email: 'myemail#gmail.com',
profile: {
firstName: 'Barry',
lastName: 'Doyle'
},
isDefault: true, //Add this field to notify the onCreateUser callback that this is default
roles: ['webmaster', 'admin']
};
Accounts.onCreateUser(function(options, user) {
if (user.isDefault) {
Meteor.users.update(user._id, {
$set: { "emails.0.verified": true}
});
}
});

How to make sign-up invitation only?

Using Meteor accounts (and accounts-ui) is there an easy way to make new user sign-ups invitation only? For example by providing an invitation link or an invitation code.
The only thing related I could find in the Meteor documentation is Meteor.sendEnrollmentEmail but it doesn't solve my problem.
You can do this with the built in package, but I found it alot easier and powerful to roll a simple implementation.
You'll need to:
Create a collection, eg UserInvitations to contain the invites to become a user.
Create UI for making UserInvitations / insert some using meteor mongo
Using iron-router or similar create a route, eg:
Router.map ->
#route 'register',
path: '/register/:invitationId'
template: 'userRegistration'
data: ->
return {
invitationId: #params.invitationId
}
onBeforeAction: ->
if Meteor.userId()?
Router.go('home')
return
When the form in userRegistration is submitted - call
Accounts.createUser({invitationId: Template.instance().data.invitationId /*,.. other fields */})
On the server, make an Accounts.onCreateUser hook to pass through the invitationId from options to the user
Accounts.onCreateUser(function(options, user){
user.invitationId = options.invitationId
return user;
});
Also, on the server make an Accounts.validateNewUser hook to check the invitationId and mark the invitation as used
Accounts.validateNewUser(function(user){
check(user.invitationId, String);
// validate invitation
invitation = UserInvitations.findOne({_id: user.invitationId, used: false});
if (!invitation){
throw new Meteor.Error(403, "Please provide a valid invitation");
}
// prevent the token being re-used.
UserInvitations.update({_id: user.invitationId, used: false}, {$set: {used: true}});
return true
});
Now, only users that have a valid unused invitationId can register.
EDIT: Oct 2014 - Updated to use meteor 0.9.x API's
To do it with the built in stuff, you can plumb together the existing Accounts.sendEnrollmentEmail - however it's a little more complicated than the other solution given.
Using the example code below, call the enroll method as such:
Meteor.call('enroll', 'john.smith', 'js#harvard.edu', {name: 'John Smith'});
Meteor will then email the user a link (You can configure the template with Accounts.emailTemplates)
When they click the link, meteor calls the function passed to Accounts.onEnrollmentLink - in this case you can take them to a password setup page; but you have to mess around with their done callback.
Modify the following code, where it says INSERT XXX HERE ; then in your code call SomeGlobalEnrollmentObjectThing.cancel() if the user cancels, or SomeGlobalEnrollmentObjectThing.complete(theUsersNewPassword) if they submit the new password.
if (Meteor.isServer){
Meteor.methods({
"enroll": function(username, email, profile){
var userId;
check(username, String);
check(email, String); // Or email validator
check(profile, {
name: String
}); // your own schema
// check that the current user is privileged (using roles package)
if (!Roles.isInRole(this.userId, 'admin')){
throw new Meteor.Error(403);
}
userId = Accounts.createUser({
username: username,
email: email,
profile: profile
});
Accounts.sendEnrollmentEmail(userId);
}
});
} else {
// uses `underscore`, `reactive-var` and `tracker` packages
function Enrollment(){
this.computation = null;
this.token = new ReactiveVar(null);
this.password = new ReactiveVar(null);
this.cancelled = new ReactiveVar(false);
this.done = null;
this._bind();
}
_.extend(Enrollment.prototype, {
_bind: function(){
Accounts.onEnrollmentLink(_.bind(this.action, this));
},
reset: function(){
this.token.set(null);
this.password.set(null);
this.cancelled.set(false);
this.done = null;
if (this.computation !== null){
this.computation.stop();
this.computation = null;
}
},
cancel: function(){
this.cancelled.set(true);
},
complete: function(password){
this.password.set(password);
},
action: function(token, done){
this.reset();
this.token.set(token);
this.done = done;
this.computation = Tracker.autorun(_.bind(this._computation, this));
// --- INSERT REDIRECT LOGIC HERE [TAKE TO PASSWORD SETUP PAGE]--- //
},
_computation: function(){
var password;
if (this.cancelled.get()){
this.reset();
this.done();
// --- INSERT REDIRECT LOGIC HERE [USER CANCELLED]--- //
} else {
password = this.password.get();
if (password !== null){
Accounts.resetPassword(this.token.get(), password, _.bind(this._complete, this));
}
}
},
_complete: function(err){
// TODO - check if we were reset before callback completed
this.reset();
this.done();
if (err){
// --- INSERT REDIRECT LOGIC HERE [RESET FAILED] --- //
} else {
// --- INSERT REDIRECT LOGIC HERE [SUCCESS] --- //
}
}
});
SomeGlobalEnrollmentObjectThing = new Enrollment();
}
I have created a specific solution to this, since all the other solutions only allow you to explicitly create password-based accounts. The t3db0t:accounts-invite package allows account creation with any service only when you allow them, such as with an 'accept invitation' route. Live demo here.

How do I use Firebase Simple Login with email & password

Firebase Simple login provides an email/password option, how do I use it? Starting from from creating a user, storing data for that user, to logging them in and out.
There are three distinct steps to be performed (let's assume you have jQuery):
1. Set up your callback
var ref = new Firebase("https://demo.firebaseio-demo.com");
var authClient = new FirebaseAuthClient(ref, function(error, user) {
if (error) {
alert(error);
return;
}
if (user) {
// User is already logged in.
doLogin(user);
} else {
// User is logged out.
showLoginBox();
}
});
2. User registration
function showLoginBox() {
...
// Do whatever DOM operations you need to show the login/registration box.
$("#registerButton").on("click", function() {
var email = $("#email").val();
var password = $("#password").val();
authClient.createUser(email, password, function(error, user) {
if (!error) {
doLogin(user);
} else {
alert(error);
}
});
});
}
3. User login
function showLoginBox() {
...
// Do whatever DOM operations you need to show the login/registration box.
$("#loginButton").on("click", function() {
authClient.login("password", {
email: $("#email").val(),
password: $("#password").val(),
rememberMe: $("#rememberCheckbox").val()
});
});
}
When the login completes successfully, the call you registered in step 1 will be called with the correct user object, at which point we call doLogin(user) which is a method you will have to implement.
The structure of the user data is very simple. It is an object containing the following properties:
email: Email address of the user
id: Unique numeric (auto-incrementing) ID for the user
FirebaseAuthClient will automatically authenticate your firebsae for you, not further action is required. You can now use something like the following in your security rules:
{
"rules": {
"users": {
"$userid": {
".read": "auth.uid == $userid",
".write": "auth.uid == $userid"
}
}
}
}
This means, if my User ID is 42, only I can write or read at example.firebaseio-demo.com/users/42 - when I am logged in - and no-one else.
Note that Simple Login does not store any additional information about the user other than their ID and email. If you want to store additional data about the user, you must do so yourself (probably in the success callback for createUser). You can store this data as you normally would store any data in Firebase - just be careful about who can read or write to this data!
Just incase someone is reached to this thread and looking for some example application using the firebase authentication. Here are two examples
var rootRef = new Firebase('https://docs-sandbox.firebaseio.com/web/uauth');
......
.....
....
http://jsfiddle.net/firebase/a221m6pb/embedded/result,js/
http://www.42id.com/articles/firebase-authentication-and-angular-js/

Resources