diff --git a/config/circleci.json b/config/circleci.json index 5e76a65..298425b 100644 --- a/config/circleci.json +++ b/config/circleci.json @@ -1,8 +1,7 @@ { + "adminClientKey": "8eaf12451134b24e", + "adminClientSecret": "faf0a67a00ab7b5477149b96d9c07e32", "adminPass": "1.TestPassword.1", - "adminSessionSecret": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", - "env": "test", - "port": 8080, "db": { "host": "127.0.0.1", "port": 5432, @@ -10,6 +9,9 @@ "user": "ubuntu", "password": "" }, + "env": "test", + "port": 8080, + "sessionSecret": "b7dde0ef7952697c2613f76c1b5e0503", "userAuth": { "sessionSecret": "v3erY secr€t", "facebook": { diff --git a/config/sample.json b/config/sample.json index fffc94a..49c8fb9 100644 --- a/config/sample.json +++ b/config/sample.json @@ -1,9 +1,7 @@ { + "adminClientKey": "default", + "adminClientSecret": "default", "adminPass": "default", - "adminSessionSecret": "default", - "env": "dev", - "port": 8080, - "publicHost": "http://localhost:8080", "db": { "host": "localhost", "port": 5432, @@ -11,13 +9,9 @@ "user": "postgres", "password": "default" }, - "userAuth": { - "sessionSecret": "default", - "facebook": { - "clientID": "", - "clientSecret": "" - } - }, + "env": "dev", + "port": 8080, + "publicHost": "http://localhost:8080", "sensorthings": { "server": "https://pg-api.sensorup.com", "path": "/st-playground/proxy/v1.0", @@ -26,5 +20,13 @@ "value": "a8654152-74c0-41dd-a954-bcab50ff99d4" } }, + "sessionSecret": "default", + "userAuth": { + "sessionSecret": "default", + "facebook": { + "clientID": "", + "clientSecret": "" + } + }, "version": "v1.0" } diff --git a/config/test.json b/config/test.json index 3dce96b..d8ce376 100644 --- a/config/test.json +++ b/config/test.json @@ -1,9 +1,7 @@ { + "adminClientKey": "3bfff46e04c7ed52", + "adminClientSecret": "2e221f61493297318678218c9ade9b4a", "adminPass": "1.TestPassword.1", - "adminSessionSecret": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", - "env": "test", - "port": 8080, - "publicHost": "http://localhost:8080", "db": { "host": "localhost", "port": 5432, @@ -11,6 +9,11 @@ "user": "postgres", "password": "default" }, + "env": "test", + "port": 8080, + "permissions": ["admin", "dummy"], + "publicHost": "http://localhost:8080", + "sessionSecret": "bf5f0df1abfd6c4e10327a0ad965fe54", "userAuth": { "sessionSecret": "v3erY secr€t", "facebook": { diff --git a/doc/API.md b/doc/API.md index b9f0473..42063e2 100644 --- a/doc/API.md +++ b/doc/API.md @@ -10,12 +10,12 @@ This document provides protocol-level details of the SensorWeb API. All requests will be to URLs of the form: - https:///api/v1/ + https://// Note that: * All API access must be over a properly-validated HTTPS connection. -* The URL embeds a version identifier "v1"; future revisions of this API may +* The URL embeds a version identifier "v1.0"; future revisions of this API may introduce new version numbers. ## Request Format @@ -39,7 +39,7 @@ Use the JWT with this header: For example: ```curl -curl 'http://localhost:3000/api/v1/clients' \ +curl 'http://localhost:3000/v1.0/clients' \ -H 'Accept: application/json' \ -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJraWQiOm51bGwsImFsZyI6IkhTMjU2In0.eyJpZCI6MiwibmFtZSI6ImFkbWluIn0.JNtvokupDl2hdqB+vER15y89qigPc4FviZfJOSR1Vso' ``` @@ -86,8 +86,8 @@ SHOULD NOT be repeated. # API Endpoints * Login - * [POST /auth/basic](#post-authbasic) - * [GET /auth/facebook](#get-authfacebook) + * [GET /auth/basic](#post-authbasic) :lock: (client signed token required) + * [GET /auth/facebook](#get-authfacebook) :lock: (client signed token required) * API clients management * [POST /clients](#post-clients) :lock: (admin scope required) * [GET /clients](#get-clients) :lock: (admin scope required) @@ -95,24 +95,31 @@ SHOULD NOT be repeated. * Permissions * [GET /permissions](#get-permissions) :lock: (admin scope required) -## POST /auth/basic -Authenticates a user using Basic authentication. So far only an admin user is +## GET /auth/basic +Authenticates a user using username and password. So far only an admin user is allowed. ### Request -Requests must include a [basic authorization header] -(https://en.wikipedia.org/wiki/Basic_access_authentication#Client_side) -with `username:password` encoded in Base64. +Requests must include a JWT signed with a valid client secret as the +`authToken` query parameter. + ```ssh -POST /api/auth/basic HTTP/1.1 -Authorization: Basic YWRtaW46QXZhbGlkUGFzc3dvcmQuMA== +GET /v1.0/auth/basic?authToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbn +RJZCI6IjhlYWYxMjQ1MTEzNGIyNGUiLCJ1c2VybmFtZSI6ImFkbWluIiwicGFzc3dvcmQiOiIxLkxv +bmdhZG1pbnBhc3MuMSIsInNjb3BlcyI6ImFkbWluIn0.foaQeXQGt5_8wFmW5mH9wdQLE3VKHwH9oD +clmUroWRk HTTP/1.1 ``` + +The payload of the signed JWT must include the following information: +* `clientKey`: client identifier, aka his key. +* `scopes`: the list of permissions the client is asking for for this token. + ### Response -Successful requests will produce a "201 Created" response with a session token +Successful requests will produce a 200 response with a session token in the form of a [JWT](https://jwt.io/) with the following data: ```json { - "id": "admin", - "scope": "admin" + "clientKey": "8eaf12451134b24e", + "scopes": ["admin"] } ``` @@ -124,9 +131,9 @@ Content-Length: 156 Content-Type: application/json; charset=utf-8 Date: Fri, 23 Sep 2016 16:22:39 GMT { - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFk - bWluIiwic2NvcGUiOiJhZG1pbiIsImlhdCI6MTQ3NDY0Nzc1O - X0.R1vQOLVg8A-6i5QaZQVOGAzImiPvgAdkWiODYhYiNn4" + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRJZCI6IjhlYWYxMjQ1MTE + zNGIyNGUiLCJzY29wZXMiOlsiYWRtaW4iXSwiaWF0IjoxNDc0NjQ3NzU5fQ.ZxnRCbuw + yCypJMnAHHhpwSL_-y19Q4DSioA1cnB9JyY" } ``` @@ -137,7 +144,7 @@ Requests must include a JWT signed with a valid client secret as the `authToken` query parameter. ```ssh -POST /api/auth/facebook?authToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb +GET /v1.0/auth/facebook?authToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb GllbnRJZCI6IjEyMzQ1Njc4OTAiLCJzY29wZXMiOlsidXNlci1mYXZvcml0ZXMiXSwiYXV0aFJlZ GlyZWN0VXJscyI6WyJodHRwczovL2RvbWFpbi5vcmcvYXV0aC9zdWNjZXNzIl0sImF1dGhGYWlsd XJlVXJscyI6WyJodHRwczovL2RvbWFpbi5vcmcvYXV0aC9lcnJvciJdfQ.e7rYEZsQNLG0aTjDRH @@ -145,8 +152,8 @@ sQ2xembu3fyVe-B9bm8mFprwQ HTTP/1.1 ``` The payload of the signed JWT must include the following information: -* `id`: client identifier, aka his key. -* `scope`: just `client` for now. +* `clientKey`: client identifier, aka his key. +* `scopes`: the list of permissions the client is asking for for this token. * `redirectUrl`: the URL you would like to be redirected after a successful login. This URL needs to be associated with your client information first. It will gets the user's JWT as a query parameter `token`. @@ -172,12 +179,8 @@ with the following data: ```json { - "id": { - "opaqueId": "facebook_id", - "provider": "facebook", - "clientKey": "02e9c791d7" - }, - "scope": "user" + "clientKey": "02e9c791d7", + "scopes": ["sensorthings"] } ``` @@ -191,7 +194,7 @@ ___Parameters___ * permissions (optional) - List of permissions the client is allowed to request. ```ssh -POST /api/clients HTTP/1.1 +POST /v1.0/clients HTTP/1.1 Content-Type: application/json Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFkbWluIiwic2NvcGUiOiJhZG1pbiIsImlhdCI6MTQ3NDY0Nzc1OX0.R1vQOLVg8A-6i5QaZQVOGAzImiPvgAdkWiODYhYiNn4 { @@ -222,7 +225,7 @@ Get the list of registered API clients. ### Request ```ssh -GET /api/clients HTTP/1.1 +GET /v1.0/clients HTTP/1.1 Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFkbWluIiwic2NvcGUiOiJhZG1pbiIsImlhdCI6MTQ3NDY0Nzc1OX0.R1vQOLVg8A-6i5QaZQVOGAzImiPvgAdkWiODYhYiNn4 ``` @@ -250,7 +253,7 @@ Deletes a registered API client given its identifier. ### Request ```ssh -DELETE /api/clients/766a06dab7358b6aec17891df1fe8555 HTTP/1.1 +DELETE /v1.0/clients/766a06dab7358b6aec17891df1fe8555 HTTP/1.1 Host: localhost:8080 ``` @@ -262,7 +265,7 @@ Get the list of client permissions. ### Request ```ssh -GET /api/permissions HTTP/1.1 +GET /v1.0/permissions HTTP/1.1 Host: localhost:8080 ``` diff --git a/src/config.js b/src/config.js index d86be93..dcf8d30 100644 --- a/src/config.js +++ b/src/config.js @@ -3,6 +3,8 @@ import fs from 'fs'; import owasp from 'owasp-password-strength-test'; import path from 'path'; +import { validator } from 'express-validator'; + const defaultValue = 'default'; const avoidDefault = value => { @@ -20,21 +22,25 @@ const password = value => { } }; +const hex = size => { + return val => { + const error = new Error('Admin client key must be an 8 chars hex string'); + if (size && val.length < size) { + throw error; + } + + if (!validator.isHexadecimal(val)) { + throw error; + } + }; +}; + convict.addFormat({ name: 'dbport', validate: val => (val === null || val >= 0 && val <= 65535), coerce: val => (val === null ? null : parseInt(val)) }); -convict.addFormat({ - name: 'hex', - validate: function(val) { - if (/[^a-fA-F0-9]/.test(val)) { - throw new Error('must be a hex key'); - } - } -}); - convict.addFormat({ name: 'arrayOfStrings', validate: val => ( @@ -44,16 +50,21 @@ convict.addFormat({ // Note: Alphabetically ordered, please. const conf = convict({ + adminClientKey: { + doc: 'Admin client key', + format: hex(16), + default: '' + }, + adminClientSecret: { + doc: 'Admin client secret', + format: hex(32), + default: '' + }, adminPass: { doc: 'The password for the admin user. Follow OWASP guidelines for passwords', format: password, default: 'invalid' }, - adminSessionSecret: { - doc: 'Secret to sign admin session tokens', - format: avoidDefault, - default: defaultValue - }, behindProxy: { doc: `Set this to true if the server runs behind a reverse proxy. This is especially important if the proxy implements HTTPS with @@ -129,6 +140,11 @@ const conf = convict({ } } }, + sessionSecret: { + doc: 'Secret to sign session tokens', + format: hex(32), + default: defaultValue + }, userAuth: { cookieSecure: { doc: `This configures whether the cookie should be set and sent for @@ -148,7 +164,7 @@ const conf = convict({ }, clientSecret: { doc: 'Facebook clientSecret', - format: 'hex' + format: hex() }, }, }, diff --git a/src/middlewares/auth.js b/src/middlewares/auth.js index 1a62492..59d554f 100644 --- a/src/middlewares/auth.js +++ b/src/middlewares/auth.js @@ -1,5 +1,20 @@ /** - * Route middleware to verify session tokens. + * This middleware handles endpoint authentication. Endpoint authentication + * is done through a signed JWT expected as value of the 'Authorization' header + * or the 'authToken' query parameter. This JWT can be signed by two + * different authorities: + * + * 1. A registered API client. + * 2. The API server. + * + * Each of these authorities will have a different secret. + * + * Clients may obtain authorization tokens: + * + * 1. On their behalf. + * 2. On behalf of an user. + * 3. On behalf of a device. + * */ import jwt from 'jsonwebtoken'; @@ -16,14 +31,21 @@ function unauthorized(res) { ApiError(res, 401, ERRNO_UNAUTHORIZED, UNAUTHORIZED); } -export default (scopes) => { - // For now we only allow 'admin' scope. - const validScopes = ['admin', 'client', 'user'].filter( - scope => scopes.includes(scope) - ); +export const SIGNED_BY_CLIENT = 'clientSigned'; +export const SIGNED_BY_SERVER = 'serverSigned'; - if (!validScopes.length) { - throw new Error(`No valid scope found in "${scopes}"`); +export default (endpointScopes, signedBy) => { + // Check that we specified who we expect to be the signer of the token + // being verified. + if (![SIGNED_BY_CLIENT, SIGNED_BY_SERVER].includes(signedBy)) { + throw new Error('Invalid token signature author'); + } + + // Check that the required scope is a registered permission. + const permissions = config.get('permissions'); + if (endpointScopes && + !endpointScopes.every(scope => permissions.includes(scope))) { + throw new Error(`Invalid permission found in "${endpointScopes}"`); } return (req, res, next) => { @@ -44,53 +66,72 @@ export default (scopes) => { return unauthorized(res); } - // Because we expect to get tokens signed with different secrets, we first - // need to get the owner of the token so we can get the appropriate secret. + // *All* auth tokens must have at least a clientKey in its payload. + // If this is a client signed token, we'll use the client identifier + // to obtain the client's secret to verify the token's signature. const decoded = jwt.decode(token); - - if (!decoded || !decoded.id || !decoded.scope) { + if (!decoded || !decoded.clientKey || !decoded.scopes) { return unauthorized(res); } - if (!validScopes.includes(decoded.scope)) { - console.log('Error while authenticating, invalid scope', decoded); - return unauthorized(res); - } + db().then(({ Clients, Users, Permissions }) => { + if (!decoded.userId) { + return Promise.resolve({ Clients, Permissions }); + } - let secretPromise; - switch(decoded.scope) { - case 'client': - secretPromise = db().then(({ Clients }) => - Clients.findById(decoded.id, { attributes: ['secret'] }) - ).then(client => client.secret); - break; - case 'user': - case 'admin': - secretPromise = Promise.resolve(config.get('adminSessionSecret')); - break; - default: - // should not happen because we check this earlier - next(new Error(`Unknown scope ${decoded.scope}`)); - } + // If this token was obtained on behalf of an user, we need to check + // that the user is still valid. + return Users.findById(decoded.userId).then(user => { + if (!user) { + throw new Error(); + } + req.userId = user.id; + return { Clients, Permissions }; + }); + }).then(({ Clients, Permissions }) => { + // Every token belongs to a client and each client has its own + // identifier. We need to verify that the client owning this token is + // still a valid client. + return Clients.findById(decoded.clientKey, { include: Permissions}); + }).then(client => { + // The client must exist. + if (!client) { + return unauthorized(res); + } + + decoded.scopes = Array.isArray(decoded.scopes) ? decoded.scopes + : [decoded.scopes]; + + const permissions = client.Permissions.map(permission => { + return permission.name; + }); - // Verify JWT signature. - secretPromise.then(secret => { - jwt.verify(token, secret, (error) => { + // Check that the token has a scope allowed for this client and + // that the required endpoint scopes are included in the token. + if (!decoded.scopes.every(scope => permissions.includes(scope)) || + !endpointScopes.every(scope => decoded.scopes.includes(scope))) { + return unauthorized(res); + } + + // We expect to get tokens signed with different secrets. + const secret = signedBy === SIGNED_BY_CLIENT ? + client.secret : + config.get('sessionSecret'); + + // Verify JWT signature. + jwt.verify(token, secret, error => { if (error) { - console.log('Error while verifying the token', error); return unauthorized(res); } - // XXX s/id/clientId/g - req.clientId = decoded.id; + req.scopes = decoded.scopes; + req.client = client; req.authPayload = decoded; - // XXX Get rid of this. Kept only to keep user tokens working. Issue #68 - req[decoded.scope] = decoded.id; - req.authScope = decoded.scope; - - return next(); + next(); }); - }).catch(err => next(err || new Error('Unexpected error'))); + }).catch(() => { + unauthorized(res); + }); }; }; diff --git a/src/models/clients.js b/src/models/clients.js index 4d332e5..70ee199 100644 --- a/src/models/clients.js +++ b/src/models/clients.js @@ -17,10 +17,10 @@ module.exports = (sequelize, DataTypes) => { }, }, secret: { - type: DataTypes.STRING(128), + type: DataTypes.STRING(32), allowNull: false, defaultValue: () => { - return randomBytes(64).toString('hex'); + return randomBytes(16).toString('hex'); } }, // Redirection URLs for user authentication flows. diff --git a/src/models/db.js b/src/models/db.js index 8e7a8f1..19ed5d1 100644 --- a/src/models/db.js +++ b/src/models/db.js @@ -74,17 +74,27 @@ export default function() { db.Sequelize = Sequelize; return sequelize.sync().then(() => { - while (deferreds.length) { - deferreds.pop().resolve(db); - } - state = READY; - // Load default permissions. const permissions = config.get('permissions').map(permission => { return { model: 'Permissions', data: { name: permission }}; }); return sequelizeFixtures.loadFixtures(permissions, db); }).then(() => { + // Load admin client. + const data = { + name: 'admin', + key: config.get('adminClientKey'), + secret: config.get('adminClientSecret'), + Permissions: [{ name: 'admin' }] + }; + return sequelizeFixtures.loadFixtures([{ model: 'Clients', data }], db); + }).then(() => { + while (deferreds.length) { + deferreds.pop().resolve(db); + } + + state = READY; + return db; }).catch(e => { console.error(e); diff --git a/src/models/users.js b/src/models/users.js index 99bbbad..97824ad 100644 --- a/src/models/users.js +++ b/src/models/users.js @@ -19,12 +19,9 @@ const authMethods = { }); }, - AUTH_PROVIDER: (data) => { + AUTH_PROVIDER: data => { // We use this authentication method only for users. - return Promise.resolve({ - id: data, - scope: 'user' - }); + return Promise.resolve(data); }, }; @@ -47,14 +44,24 @@ module.exports = (sequelize, DataTypes) => { return authMethods[method](data) .then(userData => { - if (userData.scope !== 'user') { - return userData; + if (userData.id === 'admin' && userData.scope === 'admin') { + return userData.id; } - return User.findOrCreate({ - attributes: [], - where: userData.id, // this contains all user attributes - }).then(() => userData); + let promise; + if (userData.id) { + promise = User.findById(userData.id); + } else if (userData.opaqueId) { + promise = User.create(userData); + } else { + return Promise.reject(); + } + return promise.then(user => { + if (!user) { + return Promise.reject(); + } + return user.id; + }); }); }; diff --git a/src/routes/auth/basic.js b/src/routes/auth/basic.js index dfdb74f..bd6c953 100644 --- a/src/routes/auth/basic.js +++ b/src/routes/auth/basic.js @@ -1,53 +1,25 @@ -import express from 'express'; - -import passport from 'passport'; -import { BasicStrategy } from 'passport-http'; - -import db from '../../models/db'; -import finalizeAuth from './finalize_auth'; +import db from '../../models/db'; +import express from 'express'; +import finalizeAuth from './finalize_auth'; import { ApiError, UNAUTHORIZED, ERRNO_UNAUTHORIZED } from '../../errors'; -passport.use(new BasicStrategy( - (username, password, done) => { - db().then(models => { - const { BASIC, authenticate } = models.Users; - return authenticate(BASIC, { username, password }); - }).then( - userInfo => done(null, userInfo), - err => { - if (err.message === UNAUTHORIZED) { - // passing back `false` to passport means "no user found" - done(null, false); - return; - } - done(err); - } - ); - } -)); - const router = express.Router(); -router.post('/', - (req, res, next) => { - passport.authenticate( - 'basic', - (err, user, _info) => { - // TODO we can probably remove this callback with issue #44 - if (err) { - return next(err); - } - - if (!user) { - return ApiError(res, 401, ERRNO_UNAUTHORIZED, UNAUTHORIZED); - } +router.get('/', (req, res, next) => { + const { username, password } = req.authPayload; + if (!username || !password) { + return ApiError(res, 401, ERRNO_UNAUTHORIZED, UNAUTHORIZED); + } - req.user = user; - return next(); - } - )(req, res, next); - }, - finalizeAuth -); + db().then(({ Users }) => { + const { BASIC, authenticate } = Users; + return authenticate(BASIC, { username, password }); + }).then(user => { + req.userId = user.id; + next(); + }).catch(() => { + ApiError(res, 401, ERRNO_UNAUTHORIZED, UNAUTHORIZED); + }); +}, finalizeAuth); export default router; diff --git a/src/routes/auth/facebook.js b/src/routes/auth/facebook.js index cb8be5c..d4adc7a 100644 --- a/src/routes/auth/facebook.js +++ b/src/routes/auth/facebook.js @@ -1,10 +1,11 @@ -import express from 'express'; -import passport from 'passport'; +import express from 'express'; +import passport from 'passport'; import { Strategy } from 'passport-facebook'; -import config from '../../config'; -import db from '../../models/db'; -import auth from '../../middlewares/auth'; +import config from '../../config'; +import db from '../../models/db'; +import auth from '../../middlewares/auth'; +import { SIGNED_BY_CLIENT } from '../../middlewares/auth'; import finalizeAuth from './finalize_auth'; import { ApiError, @@ -45,33 +46,19 @@ passport.use(new Strategy( } )); -function checkClientExists(req, res, next) { - db().then(({ Clients }) => - Clients.findById(req.clientId, { attributes: { exclude: ['secret'] }}) - ).then(client => { - if (client) { - req.client = client; - return next(); - } - - return ApiError(res, 403, ERRNO_FORBIDDEN, FORBIDDEN); - }); -} - function checkHasValidSession(req, res, next) { if (!req.session.valid) { return ApiError(res, 403, ERRNO_FORBIDDEN, FORBIDDEN); } - return next(); + next(); } router.get('/', - auth(['client']), - checkClientExists, + auth([], SIGNED_BY_CLIENT), (req, res, next) => { const client = req.client; - const { redirectUrl, failureUrl } = req.authPayload; + const { redirectUrl, failureUrl, scopes } = req.authPayload; const authRedirectUrls = client.authRedirectUrls || []; const authFailureRedirectUrls = client.authFailureRedirectUrls || []; @@ -88,6 +75,7 @@ router.get('/', req.session.redirectUrl = redirectUrl; req.session.failureUrl = failureUrl; req.session.clientKey = client.key; + req.session.scopes = scopes; return passport.authenticate( 'facebook', { session: false } @@ -116,8 +104,10 @@ router.get( return ApiError(res, 401, ERRNO_UNAUTHORIZED, UNAUTHORIZED); } - req.user = user; - return next(); + req.userId = user; + req.client = { key: req.session.clientKey }; + req.scopes = req.session.scopes; + next(); } )(req, res, next); }, diff --git a/src/routes/auth/finalize_auth.js b/src/routes/auth/finalize_auth.js index e310a5f..0bdc81e 100644 --- a/src/routes/auth/finalize_auth.js +++ b/src/routes/auth/finalize_auth.js @@ -3,13 +3,30 @@ import url from 'url'; import config from '../../config'; +import { + ApiError, + ERRNO_UNAUTHORIZED, + UNAUTHORIZED +} from '../../errors'; + export default function finalizeAuth(req, res) { - const userData = req.user; - delete req.user; - req[userData.scope] = userData.id; - req.authScope = userData.scope; + // XXX We will need these values for the multi-tenant and multi-user + // middlewares, but for now, let's just remove them here. + // Issues #75 and #76. + const userId = req.userId; + delete req.userId; + const clientKey = req.client.key; + delete req.client; + const scopes = req.scopes && Array.isArray(req.scopes) ? + req.scopes : [req.scopes]; + delete req.scopes; + + if (!clientKey || !scopes) { + return ApiError(res, 401, ERRNO_UNAUTHORIZED, UNAUTHORIZED); + } - const token = jwt.sign(userData, config.get('adminSessionSecret')); + const token = jwt.sign({ clientKey, userId, scopes}, + config.get('sessionSecret')); if (req.session && req.session.redirectUrl) { const redirectUrl = url.parse(req.session.redirectUrl, true); @@ -19,5 +36,5 @@ export default function finalizeAuth(req, res) { return; } - res.status(201).json({ token }); + res.status(200).json({ token }); } diff --git a/src/routes/auth/index.js b/src/routes/auth/index.js index 496e86e..751f515 100644 --- a/src/routes/auth/index.js +++ b/src/routes/auth/index.js @@ -1,18 +1,21 @@ -import express from 'express'; -import session from 'express-session'; +import express from 'express'; +import session from 'express-session'; import SequelizeStoreFactory from 'connect-session-sequelize'; -import basic from './basic'; -import facebook from './facebook'; +import basic from './basic'; +import facebook from './facebook'; -import config from '../../config'; +import config from '../../config'; import { sequelize } from '../../models/db'; +import auth from '../../middlewares/auth' +import { SIGNED_BY_CLIENT } from '../../middlewares/auth'; + const router = express.Router(); // We don't need the session handling for Basic authentication, that's why we // configure it here before inserting the session middleware. -router.use('/basic', basic); +router.use('/basic', auth([], SIGNED_BY_CLIENT), basic); // initalize sequelize with session store const SequelizeStore = SequelizeStoreFactory(session.Store); diff --git a/src/server.js b/src/server.js index 554d558..e7c3b5e 100644 --- a/src/server.js +++ b/src/server.js @@ -6,7 +6,7 @@ import logger from 'morgan-body'; import path from 'path'; import auth from './middlewares/auth' - +import { SIGNED_BY_SERVER } from './middlewares/auth'; import config from './config'; import authRouter from './routes/auth'; @@ -48,8 +48,9 @@ app.use('/', dockerflow); app.use('/', sensorthings); app.use(endpointPrefix + '/auth', authRouter); -app.use(endpointPrefix + '/clients', auth(['admin']), clients); -app.use(endpointPrefix + '/permissions', auth(['admin']), permissions); +app.use(endpointPrefix + '/clients', auth(['admin'], SIGNED_BY_SERVER), clients); +app.use(endpointPrefix + '/permissions', auth(['admin'], SIGNED_BY_SERVER), + permissions); const port = config.get('port'); app.listen(port, () => console.log(`Running on localhost:${port}`)); diff --git a/test/common.js b/test/common.js index f4d2b1d..55b95e0 100644 --- a/test/common.js +++ b/test/common.js @@ -7,9 +7,16 @@ const endpointPrefix = `/${config.get('version')}`; export function loginAsAdmin(server) { return co(function*() { - const res = yield server.post(`${endpointPrefix}/auth/basic`) - .auth('admin', config.get('adminPass')) - .expect(201) + const key = config.get('adminClientKey'); + const secret = config.get('adminClientSecret'); + const authToken = yield signClientRequest({ key, secret }, { + scopes: ['admin'], + username: 'admin', + password: config.get('adminPass') + }); + const res = yield server.get(`${endpointPrefix}/auth/basic`) + .query({ authToken }) + .expect(200) .expect(res => res.body.token); return res.body.token; @@ -28,9 +35,6 @@ export function createClient(server, adminToken, client) { } export function signClientRequest(client, payload) { - const request = Object.assign({ - id: client.key, - scope: 'client' - }, payload); + const request = Object.assign({ clientKey: client.key }, payload); return Promise.resolve(jwt.sign(request, client.secret)); } diff --git a/test/test_auth_api.js b/test/test_auth_api.js index 0d9e676..2d68324 100644 --- a/test/test_auth_api.js +++ b/test/test_auth_api.js @@ -25,56 +25,59 @@ const endpointPrefix = '/' + config.get('version'); const server = supertest(app); describe('Authentication API', () => { - // TODO Use Template Strings, promises and generators. (issue #59) - describe('POST ' + endpointPrefix + '/auth/basic', () => { - it('should respond 401 Unauthorized if there is no auth header', done => { - server.post(endpointPrefix + '/auth/basic') - .expect(401) - .end((err, res) => { - res.status.should.be.equal(401); - res.body.code.should.be.equal(401); - res.body.errno.should.be.equal(errnos[ERRNO_UNAUTHORIZED]); - res.body.error.should.be.equal(errors[UNAUTHORIZED]); - done(); - }); - }); - - it('should respond 401 Unauthorized if auth header is invalid', done => { - server.post(endpointPrefix + '/auth/basic') - .set('Authorization', 'Invalid') - .expect(401) - .end((err, res) => { - res.status.should.be.equal(401); - res.body.code.should.be.equal(401); - res.body.errno.should.be.equal(errnos[ERRNO_UNAUTHORIZED]); - res.body.error.should.be.equal(errors[UNAUTHORIZED]); - done(); - }); - }); - - it('should respond 401 Unauthorized if admin pass is incorrect', done => { - server.post(endpointPrefix + '/auth/basic') - .set('Authorization', 'Basic invalidpassword') - .expect(401) - .end((err, res) => { - res.status.should.be.equal(401); - res.body.code.should.be.equal(401); - res.body.errno.should.be.equal(errnos[ERRNO_UNAUTHORIZED]); - res.body.error.should.be.equal(errors[UNAUTHORIZED]); - done(); - }); + describe(`GET ${endpointPrefix}/auth/basic`, () => { + const endpoint = `${endpointPrefix}/auth/basic`; + const key = config.get('adminClientKey'); + const secret = config.get('adminClientSecret'); + const scopes = ['admin']; + const payload = { + scopes, + username: 'admin', + password: config.get('adminPass') + }; + + [{ + reason: 'there is no auth token', + token: () => {} + }, { + reason: 'authToken signature is invalid', + token: function*() { + yield signClientRequest( + { key, secret: 'banana'}, payload + ); + } + }, { + reason: 'admin pass is incorrect', + token: function*() { + yield signClientRequest( + { key, secret }, Object.assign(payload, { password: 'banana' }) + ); + } + }].forEach(test => { + it(`should respond 401 Unauthorized if ${test.reason}`, + function*() { + yield server.get(endpoint) + .query({ authToken: test.token() }) + .expect(401, { + code: 401, + errno: errnos[ERRNO_UNAUTHORIZED], + error: errors[UNAUTHORIZED] + }) + }); }); - it('should respond 201 Created if admin pass is correct', done => { - const pass = btoa('admin:' + config.get('adminPass')); - server.post(endpointPrefix + '/auth/basic') - .set('Authorization', 'Basic ' + pass) - .expect(200) - .end((err, res) => { - res.status.should.be.equal(201); - should.exist(res.body.token); - done(); - }); + it('should respond 200 if admin pass is correct', + function*() { + const authToken = yield signClientRequest({ key, secret }, { + scopes, + username: 'admin', + password: config.get('adminPass') + }); + const res = yield server.get(endpoint) + .query({ authToken }) + .expect(200); + const decoded = jwt.verify(res.body.token, config.get('sessionSecret')); + decoded.should.match({ clientKey: key, scopes }); }); }); @@ -90,11 +93,25 @@ describe('Authentication API', () => { ]; beforeEach(function*() { - const { Clients } = yield db(); + const { Clients, Permissions } = yield db(); yield Clients.destroy({ where: {}}); + yield Permissions.destroy({ where: {}}); + + // Admin client. + yield Permissions.create({ name: 'admin' }); + const admin = yield Clients.create({ + name: 'admin', + key: config.get('adminClientKey'), + secret: config.get('adminClientSecret'), + }); + yield admin.addPermission(['admin']); + + // Dummy permission. + yield Permissions.create({ name: 'dummy' }); }); - it('should respond 401 unauthorized if there is no client token', function*() { + it('should respond 401 unauthorized if there is no client token', + function*() { yield server.get(endpoint) .expect(401) .expect({ @@ -118,13 +135,15 @@ describe('Authentication API', () => { it('should respond 400 if the client has no redirect url', function*() { const adminToken = yield loginAsAdmin(server); - const client = yield createClient(server, adminToken, { name: 'test' }); + const client = yield createClient(server, adminToken, { + name: 'test', + permissions: ['dummy'] + }); const authToken = yield signClientRequest( - client, { redirectUrl: redirectUrls[0] } + client, { redirectUrl: redirectUrls[0], scopes: ['dummy'] } ); - yield server.get(endpoint) - .query({ authToken: authToken }) + .query({ authToken }) .expect(400); }); @@ -136,25 +155,27 @@ describe('Authentication API', () => { name: 'test', authRedirectUrls: [ redirectUrls[0] ], authFailureRedirectUrls: [ failureRedirectUrls[0] ], + permissions: ['dummy'] } ); + const payload = { scopes: ['dummy'] }; let authToken = yield signClientRequest( - client, { redirectUrl: redirectUrls[1] } + client, Object.assign(payload, { redirectUrl: redirectUrls[1] }) ); yield server.get(endpoint) .query({ authToken: authToken }) .expect(400); - authToken = yield signClientRequest(client, null); + authToken = yield signClientRequest(client, payload); yield server.get(endpoint) .query({ authToken: authToken }) .expect(400); authToken = yield signClientRequest( - client, { redirectUrl: redirectUrls[0] } + client, Object.assign(payload, { redirectUrl: redirectUrls[0] }) ); yield server.get(endpoint) @@ -176,18 +197,20 @@ describe('Authentication API', () => { name: 'test', authRedirectUrls: redirectUrls, authFailureRedirectUrls: failureRedirectUrls, + permissions: ['dummy'] } ); const authToken = yield signClientRequest(client, { redirectUrl: redirectUrls[1], - failureUrl: failureRedirectUrls[1] + failureUrl: failureRedirectUrls[1], + scopes: ['dummy'] }); // Supertest's agent keeps the cookies const agent = supertest.agent(app); let res = yield agent.get(endpoint) - .query({ authToken: authToken }) + .query({ authToken }) .expect(302) .expect('location', /facebook\.com/) .expect('set-cookie', /^connect\.sid\.auth=/); @@ -220,17 +243,23 @@ describe('Authentication API', () => { .expect( 'location', new RegExp(`^${redirectUrls[1]}\\?token=`) ); + + const { Users } = yield db(); + const user = yield Users.findOne({ where: expectedId }); + should.exist(user); + const token = url.parse(res.headers.location, true).query.token; - const decodedToken = jwt.verify(token, config.get('adminSessionSecret')); - decodedToken.should.match({ id: expectedId, scope: 'user' }); + const decodedToken = jwt.verify(token, config.get('sessionSecret')); + decodedToken.should.match({ + userId: user.id, + clientKey: client.key, + scopes: ['dummy'] + }); facebook.done(); nock.cleanAll(); nock.restore(); - const { Users } = yield db(); - const user = yield Users.findOne({ where: expectedId }); - should.exist(user); }); }); });