Implement authentication through a Facebook flow#47
Implement authentication through a Facebook flow#47ferjm merged 38 commits intomozilla-sensorweb:masterfrom
Conversation
src/config.js
Outdated
| port: { | ||
| doc: 'Port where PostgreSQL is running', | ||
| format: 'port', | ||
| format: '*', |
There was a problem hiding this comment.
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 :) ).
There was a problem hiding this comment.
but I'm fine not committing this and keeping the change locally
There was a problem hiding this comment.
Ok, thanks for letting me know. Maybe add a comment explaining why the format is*.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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); | ||
| } | ||
|
|
There was a problem hiding this comment.
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 | ||
| }); |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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' | ||
| }); |
There was a problem hiding this comment.
basic authentication is for admin only
| AUTH_PROVIDER: (data) => { | ||
| return Promise.resolve({ | ||
| id: data, | ||
| scope: 'user' |
There was a problem hiding this comment.
auth provider based login is for users only
src/models/users.js
Outdated
| attributes: [], | ||
| where: userData.id, // this contains all user attributes | ||
| }) | ||
| .then(() => userData); |
There was a problem hiding this comment.
not sure whether I should put a real user object in userData.id
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Also, uber-nit: weird indentation.
There was a problem hiding this comment.
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.idis an object withopaqueId,providerandClientKey. 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.idis'admin' -
userData.scope === 'client'=>userData.idis just the client's key
So once we know the scope we also know how to consult the id.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
unauthorized errors means "no user", passing false is how passport understands this
There was a problem hiding this comment.
Add this as a code comment, please.
| (req, res, next) => { | ||
| passport.authenticate( | ||
| 'facebook', | ||
| (err, user, _info) => { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
no session for the basic auth, that's why I use it before doing the session stuff
There was a problem hiding this comment.
Add an inline comment about this, please.
src/routes/auth/index.js
Outdated
| secure: isProduction, | ||
| }, | ||
| name: 'connect.sid.auth', | ||
| proxy: isProduction, // TODO maybe better configure this in config |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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 :)
|
feedback? @ferjm Now I need to work on tests and clean up some style :) |
|
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: |
config/sample.json
Outdated
| "user": "postgres", | ||
| "password": "default" | ||
| }, | ||
| "facebook": { |
There was a problem hiding this comment.
nit: could we embed this within an userAuth object?
package.json
Outdated
| "passport-facebook": "^2.1.1", | ||
| "passport-google-oauth": "^1.0.0", | ||
| "passport-http": "^0.3.0", | ||
| "passport-twitter": "^1.0.4", |
There was a problem hiding this comment.
I guess we don't need some of these for now (i.e. google, twitter)
| format: password, | ||
| default: 'invalid' | ||
| }, | ||
| adminSessionSecret: { |
There was a problem hiding this comment.
s/adminSessionSecret/sessionSecret/g
There was a problem hiding this comment.
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: '*', |
There was a problem hiding this comment.
Ok, thanks for letting me know. Maybe add a comment explaining why the format is*.
| } | ||
| } else { | ||
| token = authQuery; | ||
| } |
There was a problem hiding this comment.
Maybe:
const token = (authHeader && authHeader.split('Bearer ')[1]) || authQuery;
if (!token) {
return unauthorized(res);
}There was a problem hiding this comment.
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`; |
There was a problem hiding this comment.
Could we move the last part also to the config, please?
There was a problem hiding this comment.
you mean the callback part ? In that case we can make callback the default ?
Just wondering: why do you think it's needed ?
There was a problem hiding this comment.
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.
src/routes/auth/facebook.js
Outdated
| }, | ||
| function(req, accessToken, refreshToken, profile, cb) { | ||
| db() | ||
| .then(models => { |
src/models/users.js
Outdated
| attributes: [], | ||
| where: userData.id, // this contains all user attributes | ||
| }) | ||
| .then(() => userData); |
There was a problem hiding this comment.
Also, uber-nit: weird indentation.
src/models/users.js
Outdated
| }, { | ||
| indexes: [{ | ||
| unique: true, | ||
| fields: ['opaqueId', 'provider', 'ClientKey'] |
There was a problem hiding this comment.
Could you add a comment about what's ClientKey for? Also, does it need to be in capital letters?
There was a problem hiding this comment.
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 ?
There was a problem hiding this comment.
If it is not too much effort, configuring the fk explicitly sounds good. It's not a big deal though.
src/routes/auth/facebook.js
Outdated
|
|
||
| function checkClientExists(req, res, next) { | ||
| db() | ||
| .then(({ Clients }) => Clients.findById(req.user.id)) |
There was a problem hiding this comment.
Could we s/req.user/req.client/g?
There was a problem hiding this comment.
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 ?
There was a problem hiding this comment.
Yes, this looks more elegant. Thanks.
There was a problem hiding this comment.
Also, just to be extra careful, could you exclude the client secret from the query, please?
7638745 to
07ba7f7
Compare
| 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: '*', |
There was a problem hiding this comment.
format is "*" because otherwise convict infers it's a boolean from the default and will refuse 1 or "auto".
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
these 2 lines are repeated a few times, it's likely a good idea to factorize this, but maybe in a separate patch ?
There was a problem hiding this comment.
Probably, but where are the repetitions? :)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
File another issue and reference it in the code, please :)
There was a problem hiding this comment.
after all I just did what I suggested in a separate commit. Easier than creating an issue :) Tell me what you think.
| app.use(bodyParser.json()); | ||
|
|
||
| if (config.get('env') !== 'test') { | ||
| if (config.get('env') !== 'test' || process.env.FORCE_OUTPUT) { |
There was a problem hiding this comment.
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 :)
There was a problem hiding this comment.
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*() { |
There was a problem hiding this comment.
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 :)
There was a problem hiding this comment.
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 | |||
There was a problem hiding this comment.
This is a sugar helper to wrap the mocha tests in co if it's a generator.
| done(); | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
File issues for these suggestions and reference them in the code, please.
| } from './common'; | ||
|
|
||
| const endpointPrefix = '/' + config.get('version'); | ||
| const server = supertest(app); |
There was a problem hiding this comment.
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') |
There was a problem hiding this comment.
this "before" could use the common file now, but maybe later in a separate patch
There was a problem hiding this comment.
Could you file an issue and add a XXX comment here referencing it, please?
| @@ -1,76 +0,0 @@ | |||
| import btoa from 'btoa'; | |||
There was a problem hiding this comment.
renamed to test_auth_api
| "should": "^11.1.0", | ||
| "supertest": "^2.0.0" | ||
| "supertest": "^2.0.0", | ||
| "supertest-as-promised": "^4.0.2" |
There was a problem hiding this comment.
this is nice as we can work with promises instead of using end + done callback.
|
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. |
|
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 :) |
| "should": "^11.1.0", | ||
| "supertest": "^2.0.0" | ||
| "supertest": "^2.0.0", | ||
| "supertest-as-promised": "^4.0.2" |
| 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: '*', |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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') |
There was a problem hiding this comment.
Could you file an issue and add a XXX comment here referencing it, please?
| done(); | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
File issues for these suggestions and reference them in the code, please.
test/test_auth_api.js
Outdated
| .expect('location', /facebook\.com/) | ||
| .expect('set-cookie', /^connect\.sid\.auth=/); | ||
|
|
||
| // This is Faacebook's anti-CSRF protection |
Closes #24