Skip to content

Implement authentication through a Facebook flow#47

Merged
ferjm merged 38 commits intomozilla-sensorweb:masterfrom
julienw:user-management-3
Jan 19, 2017
Merged

Implement authentication through a Facebook flow#47
ferjm merged 38 commits intomozilla-sensorweb:masterfrom
julienw:user-management-3

Conversation

@julienw
Copy link
Collaborator

@julienw julienw commented Jan 13, 2017

Closes #24

src/config.js Outdated
port: {
doc: 'Port where PostgreSQL is running',
format: 'port',
format: '*',
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these changes are needed for me because I connect to psql through a unix socket (which is quite cool because it matches psql user with your user :) ).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but I'm fine not committing this and keeping the change locally

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, thanks for letting me know. Maybe add a comment explaining why the format is*.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's probably better to do something in a separate patch; possibly with a custom verifier. I'll revert this from this patch for now.

const authHeader = req.headers['authorization'];
if (!authHeader) {
const authHeader = req.headers.authorization;
const authQuery = req.query.authorizationToken;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be able to use a simple browser request, we can pass the JWT token in this query parameter. I haven't looked if there is anything kinda standardized for this yet.

if (!decoded || !decoded.id || !decoded.scope) {
return unauthorized(res);
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: directly calling unauthorized disallows the redirect to failureUrl; this will be fixed once we just pass an error to next() so that we can handle it (or not) in the next middleware.

port,
dialect: 'postgres',
logging: false
});
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extracting this out of the exported function makes it possible to export it so that we can use it elsewhere. (in my case: for the session store)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this makes a big perf difference, as I believe what makes the big perf hit is loading all the model and calling sync

return Promise.resolve({
id: 'admin',
scope: 'admin'
});
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

basic authentication is for admin only

AUTH_PROVIDER: (data) => {
return Promise.resolve({
id: data,
scope: 'user'
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

auth provider based login is for users only

attributes: [],
where: userData.id, // this contains all user attributes
})
.then(() => userData);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure whether I should put a real user object in userData.id

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is probably safer to create a new object only with the fields that we care about (i.e., opaqueId and provider). Sequelize adds some extra fields like createdAt and updatedAt that we should remove from the response.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, uber-nit: weird indentation.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that the result to authenticate is not returned to the user, rather it's stored in req.user for the next (express) middlewares.

Currently I'm not returning the sequelize's user at all. userData comes from the authentication methods. Depending on the methods we don't always have the same structure. My take at this is that the "scope" will give us the structure.

  • userData.scope === 'user' => userData.id is an object with opaqueId, provider and ClientKey. This looks like the sequelize object but it's not. It comes from the authentication methods to we rely on them giving us the right structure (but a wrong structure would make the find fail, so I think we're good).

    My main issue with the current approach is that in this function we never know explicitely what's inside userData.id, that could make it more difficult to read the function.

  • userData.scope === 'admin' => userData.id is 'admin'

  • userData.scope === 'client' => userData.id is just the client's key

So once we know the scope we also know how to consult the id.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: the indentation comes from my work with gmarty :) each .then go a new line even if it was not completely needed. I liked the fact it was a systematic rule so I kind of adopted it too, but I can also adapt myself to another code style :D

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok. Thanks for the explanation.

Regarding the indentation, it's not a big deal, but rather that introducing a new one, try to follow the existing style, please.

return User.findOrCreate({
...
}).then(...

or even

return User.findOrCreate({
...
})
.then(...

userInfo => done(null, userInfo),
err => {
if (err.message === UNAUTHORIZED) {
done(null, false);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unauthorized errors means "no user", passing false is how passport understands this

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add this as a code comment, please.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure

(req, res, next) => {
passport.authenticate(
'facebook',
(err, user, _info) => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using a callback is the only way I found to customize what to do when there is a failure


const router = express.Router();

router.use('/basic', basic);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no session for the basic auth, that's why I use it before doing the session stuff

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add an inline comment about this, please.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure

secure: isProduction,
},
name: 'connect.sid.auth',
proxy: isProduction, // TODO maybe better configure this in config
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is when node is in HTTP but there is a proxy in HTTPS, when we use secure: true above

if (req.session && req.session.redirectUrl) {
const redirectUrl = url.parse(req.session.redirectUrl, true);
redirectUrl.query.token = token;
req.session.destroy();
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'll need to destroy this in case of failures too... but not so easy to do with the current way of managing errors :)

@julienw
Copy link
Collaborator Author

julienw commented Jan 13, 2017

feedback? @ferjm

Now I need to work on tests and clean up some style :)

@julienw
Copy link
Collaborator Author

julienw commented Jan 13, 2017

BTW I use https://github.com/julienw/sensorweb-login-website to start my login flow. Here are the informations for my client with the hardcoded JWT:

name: julien
key: 7c91e8d619046ad7
secret: efa0a85e98bfc7929e66a18e14db5cdae4fce6517dc48e3c138fcf3d207b94b3af883985a7134adef94790b59acdb1be220011f8aff903cab8729cbdb45005ff
authRedirectUrls: {http://localhost:10000/token.html}
authFailureRedirectUrls: {http://localhost:10000/tokenFailure.html}

Copy link
Member

@ferjm ferjm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you Julien. Great work!

Looks good overall. I only left a few comments.

I know you are working on tests now, but I am also missing some high level and API documentation.

"user": "postgres",
"password": "default"
},
"facebook": {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could we embed this within an userAuth object?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure !

package.json Outdated
"passport-facebook": "^2.1.1",
"passport-google-oauth": "^1.0.0",
"passport-http": "^0.3.0",
"passport-twitter": "^1.0.4",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we don't need some of these for now (i.e. google, twitter)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ooops right :)

format: password,
default: 'invalid'
},
adminSessionSecret: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/adminSessionSecret/sessionSecret/g

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, we discussed to do it in a separate patch because it's orthogonal and I didn't want to cram too much things into this patch. But I should probably revert the next change then.

src/config.js Outdated
port: {
doc: 'Port where PostgreSQL is running',
format: 'port',
format: '*',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, thanks for letting me know. Maybe add a comment explaining why the format is*.

}
} else {
token = authQuery;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe:

const token = (authHeader && authHeader.split('Bearer ')[1]) || authQuery;
if (!token) {
  return unauthorized(res);
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah why not; I can use a ternary which can be a tiny bit more readable.

const router = express.Router();

const callbackURL =
`${config.get('publicHost')}/${config.get('version')}/auth/facebook/callback`;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we move the last part also to the config, please?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you mean the callback part ? In that case we can make callback the default ?

Just wondering: why do you think it's needed ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, basically all the hard-coded part. Having it in the config, in a common place for all IdP configuration, makes things a little bit easier, specially when trying to develop locally.

},
function(req, accessToken, refreshToken, profile, cb) {
db()
.then(models => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: weird indentation

attributes: [],
where: userData.id, // this contains all user attributes
})
.then(() => userData);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, uber-nit: weird indentation.

}, {
indexes: [{
unique: true,
fields: ['opaqueId', 'provider', 'ClientKey']
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a comment about what's ClientKey for? Also, does it need to be in capital letters?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's created automatically by sequelize with the association we create in associate.

I agree this is not really reader-friendly because the association is done quite a few lines below. A comment will definitely help here.

I can also configure explicitely the foreign key so that we control the name, and then we can have a lowercase as first letter. Do you think it's better even with the comment ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it is not too much effort, configuring the fk explicitly sounds good. It's not a big deal though.


function checkClientExists(req, res, next) {
db()
.then(({ Clients }) => Clients.findById(req.user.id))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we s/req.user/req.client/g?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually, it seems to be a standard practice in express apps that the result of the authentications is in req.user. Also req.user can contains the information for clients, users or admin (and later: sensors).

The alternative is that instead of having a scope we could either set req.user req.client req.admin and req.sensor with the information we currently have in id. This looks quite more elegant to me. What do you think ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this looks more elegant. Thanks.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, just to be extra careful, could you exclude the client secret from the query, please?

userAuth.cookieSecure. Also with this Express will trust the
X-Forwarded-For header. Set to 1 or auto if you're behind a proxy.`,
default: false,
format: '*',
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

format is "*" because otherwise convict infers it's a boolean from the default and will refuse 1 or "auto".

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add all these comments as in code comments, please?

const validScopes = ['admin'].filter(scopeIndex => {
return scopes.indexOf(scopeIndex) != -1;
req[decoded.scope] = decoded.id;
req.authScope = decoded.scope;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these 2 lines are repeated a few times, it's likely a good idea to factorize this, but maybe in a separate patch ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably, but where are the repetitions? :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are 3 locations where we set req[XXX.scope] and req.authScope: here, and in all auth/<method>.js (basic, facebook, and later google and fxaccounts likely).

That said, maybe we don't want this in auth/<method>.js where it's only consumed by finalizeAuth; we could simply use req.user or even something else like req.auth.

I'll think of this and maybe we can rework it when implementing google and fx accounts.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File another issue and reference it in the code, please :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

after all I just did what I suggested in a separate commit. Easier than creating an issue :) Tell me what you think.

@coveralls
Copy link

Coverage Status

Coverage decreased (-0.5%) to 85.561% when pulling df6d2a9 on julienw:user-management-3 into 4900a97 on mozilla-sensorweb:master.

app.use(bodyParser.json());

if (config.get('env') !== 'test') {
if (config.get('env') !== 'test' || process.env.FORCE_OUTPUT) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to be able to see the output in tests
The alternative is to have an env like "QUIET=1" to remove the logger, and set it in travis.

That said, I usually prefer to have more log than less log :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine with both options as long as we have a way to keep the logging quite.

const endpointPrefix = `/${config.get('version')}`;

export function loginAsAdmin(server) {
return co(function*() {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you're not familiar with co, it returns a Promise and takes a generator as argument; this makes it easier to deal with Promise-returning functions. yield will wait for the Promise to be resolved, rejection translates to exceptions, etc. This is so good :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we could just use promise chains because it stays quite simple, still I think it's more readable like this.

@@ -0,0 +1,2 @@
--require co-mocha
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a sugar helper to wrap the mocha tests in co if it's a generator.

done();
});
});
});
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only grouped them inside the same describe + the endpoint, no other change. There are few things we could do though: template strings, returning a promise instead of using done, etc.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File issues for these suggestions and reference them in the code, please.

} from './common';

const endpointPrefix = '/' + config.get('version');
const server = supertest(app);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not an agent anymore; an agent keeps state between calls and I think we should do it only when needed.

before((done) => {
const pass = btoa('admin:' + config.get('adminPass'));
server.post(endpointPrefix + '/users/auth')
server.post(endpointPrefix + '/auth/basic')
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this "before" could use the common file now, but maybe later in a separate patch

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you file an issue and add a XXX comment here referencing it, please?

@@ -1,76 +0,0 @@
import btoa from 'btoa';
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renamed to test_auth_api

"should": "^11.1.0",
"supertest": "^2.0.0"
"supertest": "^2.0.0",
"supertest-as-promised": "^4.0.2"
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is nice as we can work with promises instead of using end + done callback.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

@julienw
Copy link
Collaborator Author

julienw commented Jan 18, 2017

I haven't checked if travis/circleci works yet. I think circleci doesn't because I haven't adapted the config file yet.

also there is a breaking change: the endpoint to login changed, so we'll need to update the admin panel.

@coveralls
Copy link

Coverage Status

Coverage decreased (-0.5%) to 85.561% when pulling 00649fa on julienw:user-management-3 into 4900a97 on mozilla-sensorweb:master.

@julienw
Copy link
Collaborator Author

julienw commented Jan 18, 2017

CircleCi is green now, so @ferjm r?

If you review only the changes from last time, please note I reverted some of the lint changes I did in the first patch. So if you see semicolons disappearing, dont be surprised :)

Copy link
Member

@ferjm ferjm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you Julien. Great work!

"should": "^11.1.0",
"supertest": "^2.0.0"
"supertest": "^2.0.0",
"supertest-as-promised": "^4.0.2"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

userAuth.cookieSecure. Also with this Express will trust the
X-Forwarded-For header. Set to 1 or auto if you're behind a proxy.`,
default: false,
format: '*',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add all these comments as in code comments, please?

const validScopes = ['admin'].filter(scopeIndex => {
return scopes.indexOf(scopeIndex) != -1;
req[decoded.scope] = decoded.id;
req.authScope = decoded.scope;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably, but where are the repetitions? :)

before((done) => {
const pass = btoa('admin:' + config.get('adminPass'));
server.post(endpointPrefix + '/users/auth')
server.post(endpointPrefix + '/auth/basic')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you file an issue and add a XXX comment here referencing it, please?

done();
});
});
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

File issues for these suggestions and reference them in the code, please.

.expect('location', /facebook\.com/)
.expect('set-cookie', /^connect\.sid\.auth=/);

// This is Faacebook's anti-CSRF protection
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo: Facebook

@coveralls
Copy link

Coverage Status

Coverage decreased (-0.5%) to 85.561% when pulling 2b93393 on julienw:user-management-3 into 4900a97 on mozilla-sensorweb:master.

@julienw julienw changed the title Implement authentication through a Facebook flow Implement authentication through a Facebook flow (Closes #24) Jan 19, 2017
@julienw julienw changed the title Implement authentication through a Facebook flow (Closes #24) Implement authentication through a Facebook flow Jan 19, 2017
@coveralls
Copy link

Coverage Status

Coverage decreased (-0.4%) to 85.6% when pulling c5a5f6b on julienw:user-management-3 into 4900a97 on mozilla-sensorweb:master.

@ferjm ferjm merged commit d17ece3 into mozilla-sensorweb:master Jan 19, 2017
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants