'use strict'; var appdb = require('../appdb'), apps = require('../apps'), assert = require('assert'), authcodedb = require('../authcodedb'), clients = require('../clients'), ClientsError = clients.ClientsError, config = require('../config.js'), constants = require('../constants'), DatabaseError = require('../databaseerror'), debug = require('debug')('box:routes/oauth2'), eventlog = require('../eventlog.js'), hat = require('hat'), HttpError = require('connect-lastmile').HttpError, middleware = require('../middleware/index.js'), oauth2orize = require('oauth2orize'), passport = require('passport'), querystring = require('querystring'), session = require('connect-ensure-login'), tokendb = require('../tokendb'), url = require('url'), user = require('../user.js'), UserError = user.UserError, util = require('util'), _ = require('underscore'); function auditSource(req, appId) { var ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress || null; return { authType: 'oauth', ip: ip, appId: appId }; } // create OAuth 2.0 server var gServer = oauth2orize.createServer(); // Register serialialization and deserialization functions. // // The client id is stored in the session and can thus be retrieved for each // step in the oauth flow transaction, which involves multiple http requests. gServer.serializeClient(function (client, callback) { return callback(null, client.id); }); gServer.deserializeClient(function (id, callback) { clients.get(id, callback); }); // Register supported grant types. // Grant authorization codes. The callback takes the `client` requesting // authorization, the `redirectURI` (which is used as a verifier in the // subsequent exchange), the authenticated `user` granting access, and // their response, which contains approved scope, duration, etc. as parsed by // the application. The application issues a code, which is bound to these // values, and will be exchanged for an access token. gServer.grant(oauth2orize.grant.code({ scopeSeparator: ',' }, function (client, redirectURI, user, ares, callback) { debug('grant code:', client.id, redirectURI, user.id, ares); var code = hat(256); var expiresAt = Date.now() + 60 * 60000; // 1 hour authcodedb.add(code, client.id, user.id, expiresAt, function (error) { if (error) return callback(error); debug('grant code: new auth code for client %s code %s', client.id, code); callback(null, code); }); })); gServer.grant(oauth2orize.grant.token({ scopeSeparator: ',' }, function (client, user, ares, callback) { debug('grant token:', client.id, user.id, ares); var token = tokendb.generateToken(); var expires = Date.now() + constants.DEFAULT_TOKEN_EXPIRATION; tokendb.add(token, user.id, client.id, expires, client.scope, function (error) { if (error) return callback(error); debug('grant token: new access token for client %s token %s', client.id, token); callback(null, token); }); })); // Exchange authorization codes for access tokens. The callback accepts the // `client`, which is exchanging `code` and any `redirectURI` from the // authorization request for verification. If these values are validated, the // application issues an access token on behalf of the user who authorized the // code. gServer.exchange(oauth2orize.exchange.code(function (client, code, redirectURI, callback) { debug('exchange:', client, code, redirectURI); authcodedb.get(code, function (error, authCode) { if (error && error.reason === DatabaseError.NOT_FOUND) return callback(null, false); if (error) return callback(error); if (client.id !== authCode.clientId) return callback(null, false); authcodedb.del(code, function (error) { if(error) return callback(error); var token = tokendb.generateToken(); var expires = Date.now() + constants.DEFAULT_TOKEN_EXPIRATION; tokendb.add(token, authCode.userId, authCode.clientId, expires, client.scope, function (error) { if (error) return callback(error); debug('exchange: new access token for client %s token %s', client.id, token); callback(null, token); }); }); }); })); // overwrite the session.ensureLoggedIn to not use res.redirect() due to a chrome bug not sending cookies on redirects session.ensureLoggedIn = function (redirectTo) { assert.strictEqual(typeof redirectTo, 'string'); return function (req, res, next) { if (!req.isAuthenticated || !req.isAuthenticated()) { if (req.session) { req.session.returnTo = req.originalUrl || req.url; } res.status(200).send(util.format('', redirectTo)); } else { next(); } }; }; function renderTemplate(res, template, data) { assert.strictEqual(typeof res, 'object'); assert.strictEqual(typeof template, 'string'); assert.strictEqual(typeof data, 'object'); res.render(template, data); } function sendErrorPageOrRedirect(req, res, message) { assert.strictEqual(typeof req, 'object'); assert.strictEqual(typeof res, 'object'); assert.strictEqual(typeof message, 'string'); debug('sendErrorPageOrRedirect: returnTo %s.', req.query.returnTo, message); if (typeof req.query.returnTo !== 'string') { renderTemplate(res, 'error', { adminOrigin: config.adminOrigin(), message: message, title: 'Cloudron Error' }); } else { var u = url.parse(req.query.returnTo); if (!u.protocol || !u.host) { return renderTemplate(res, 'error', { adminOrigin: config.adminOrigin(), message: 'Invalid request. returnTo query is not a valid URI. ' + message, title: 'Cloudron Error' }); } res.redirect(util.format('%s//%s', u.protocol, u.host)); } } // use this instead of sendErrorPageOrRedirect(), in case we have a returnTo provided in the query, to avoid login loops // This usually happens when the OAuth client ID is wrong function sendError(req, res, message) { assert.strictEqual(typeof req, 'object'); assert.strictEqual(typeof res, 'object'); assert.strictEqual(typeof message, 'string'); renderTemplate(res, 'error', { adminOrigin: config.adminOrigin(), message: message, title: 'Cloudron Error' }); } // -> GET /api/v1/session/login function loginForm(req, res) { if (typeof req.session.returnTo !== 'string') return sendErrorPageOrRedirect(req, res, 'Invalid login request. No returnTo provided.'); var u = url.parse(req.session.returnTo, true); if (!u.query.client_id) return sendErrorPageOrRedirect(req, res, 'Invalid login request. No client_id provided.'); function render(applicationName, applicationLogo) { renderTemplate(res, 'login', { adminOrigin: config.adminOrigin(), csrf: req.csrfToken(), applicationName: applicationName, applicationLogo: applicationLogo, error: req.query.error || null, title: applicationName + ' Login' }); } clients.get(u.query.client_id, function (error, result) { if (error) return sendError(req, res, 'Unknown OAuth client'); switch (result.type) { case clients.TYPE_BUILT_IN: return render(result.appId, '/api/v1/cloudron/avatar'); case clients.TYPE_EXTERNAL: return render(result.appId, '/api/v1/cloudron/avatar'); case clients.TYPE_SIMPLE_AUTH: return sendError(req, res, 'Unknown OAuth client'); default: break; } appdb.get(result.appId, function (error, result) { if (error) return sendErrorPageOrRedirect(req, res, 'Unknown Application for those OAuth credentials'); var applicationName = result.location || config.fqdn(); render(applicationName, '/api/v1/apps/' + result.id + '/icon'); }); }); } // -> POST /api/v1/session/login function login(req, res) { var returnTo = req.session.returnTo || req.query.returnTo; var failureQuery = querystring.stringify({ error: 'Invalid username or password', returnTo: returnTo }); passport.authenticate('local', { failureRedirect: '/api/v1/session/login?' + failureQuery })(req, res, function () { res.redirect(returnTo); }); } // -> GET /api/v1/session/logout function logout(req, res) { req.logout(); if (req.query && req.query.redirect) res.redirect(req.query.redirect); else res.redirect('/'); } // Form to enter email address to send a password reset request mail // -> GET /api/v1/session/password/resetRequest.html function passwordResetRequestSite(req, res) { renderTemplate(res, 'password_reset_request', { adminOrigin: config.adminOrigin(), csrf: req.csrfToken(), title: 'Cloudron Password Reset' }); } // This route is used for above form submission // -> POST /api/v1/session/password/resetRequest function passwordResetRequest(req, res, next) { assert.strictEqual(typeof req.body, 'object'); if (typeof req.body.identifier !== 'string') return next(new HttpError(400, 'Missing identifier')); debug('passwordResetRequest: email or username %s.', req.body.identifier); user.resetPasswordByIdentifier(req.body.identifier, function (error) { if (error && error.reason !== UserError.NOT_FOUND) { console.error(error); return sendErrorPageOrRedirect(req, res, 'User not found'); } res.redirect('/api/v1/session/password/sent.html'); }); } // -> GET /api/v1/session/password/sent.html function passwordSentSite(req, res) { renderTemplate(res, 'password_reset_sent', { adminOrigin: config.adminOrigin(), title: 'Cloudron Password Reset' }); } function renderAccountSetupSite(res, req, userObject, error) { renderTemplate(res, 'account_setup', { adminOrigin: config.adminOrigin(), user: userObject, error: error, csrf: req.csrfToken(), resetToken: req.query.reset_token || req.body.resetToken, title: 'Cloudron Password Setup' }); } // -> GET /api/v1/session/account/setup.html function accountSetupSite(req, res) { if (!req.query.reset_token) return sendError(req, res, 'Missing Reset Token'); user.getByResetToken(req.query.reset_token, function (error, userObject) { if (error) return sendError(req, res, 'Invalid Reset Token'); renderAccountSetupSite(res, req, userObject, ''); }); } // -> POST /api/v1/session/account/setup function accountSetup(req, res, next) { assert.strictEqual(typeof req.body, 'object'); if (typeof req.body.resetToken !== 'string') return next(new HttpError(400, 'Missing resetToken')); if (typeof req.body.password !== 'string') return next(new HttpError(400, 'Missing password')); if (typeof req.body.username !== 'string') return next(new HttpError(400, 'Missing username')); if (typeof req.body.displayName !== 'string') return next(new HttpError(400, 'Missing displayName')); debug('acountSetup: with token %s.', req.body.resetToken); user.getByResetToken(req.body.resetToken, function (error, userObject) { if (error) return sendError(req, res, 'Invalid Reset Token'); var data = _.pick(req.body, 'username', 'displayName'); user.update(userObject.id, data, auditSource(req), function (error) { if (error && error.reason === UserError.ALREADY_EXISTS) return renderAccountSetupSite(res, req, userObject, 'Username already exists'); if (error && error.reason === UserError.BAD_FIELD) return renderAccountSetupSite(res, req, userObject, error.message); if (error && error.reason === UserError.NOT_FOUND) return renderAccountSetupSite(res, req, userObject, 'No such user'); if (error) return next(new HttpError(500, error)); userObject.username = req.body.username; userObject.displayName = req.body.displayName; // setPassword clears the resetToken user.setPassword(userObject.id, req.body.password, function (error, result) { if (error && error.reason === UserError.BAD_FIELD) return renderAccountSetupSite(res, req, userObject, error.message); if (error) return next(new HttpError(500, error)); res.redirect(util.format('%s?accessToken=%s&expiresAt=%s', config.adminOrigin(), result.token, result.expiresAt)); }); }); }); } // -> GET /api/v1/session/password/reset.html function passwordResetSite(req, res, next) { if (!req.query.reset_token) return next(new HttpError(400, 'Missing reset_token')); user.getByResetToken(req.query.reset_token, function (error, user) { if (error) return next(new HttpError(401, 'Invalid reset_token')); renderTemplate(res, 'password_reset', { adminOrigin: config.adminOrigin(), user: user, csrf: req.csrfToken(), resetToken: req.query.reset_token, title: 'Cloudron Password Reset' }); }); } // -> POST /api/v1/session/password/reset function passwordReset(req, res, next) { assert.strictEqual(typeof req.body, 'object'); if (typeof req.body.resetToken !== 'string') return next(new HttpError(400, 'Missing resetToken')); if (typeof req.body.password !== 'string') return next(new HttpError(400, 'Missing password')); debug('passwordReset: with token %s.', req.body.resetToken); user.getByResetToken(req.body.resetToken, function (error, userObject) { if (error) return next(new HttpError(401, 'Invalid resetToken')); if (!userObject.username) return next(new HttpError(401, 'No username set')); // setPassword clears the resetToken user.setPassword(userObject.id, req.body.password, function (error, result) { if (error && error.reason === UserError.BAD_FIELD) return next(new HttpError(406, error.message)); if (error) return next(new HttpError(500, error)); res.redirect(util.format('%s?accessToken=%s&expiresAt=%s', config.adminOrigin(), result.token, result.expiresAt)); }); }); } // The callback page takes the redirectURI and the authCode and redirects the browser accordingly // // -> GET /api/v1/session/callback var callback = [ session.ensureLoggedIn('/api/v1/session/login'), function (req, res) { renderTemplate(res, 'callback', { adminOrigin: config.adminOrigin(), callbackServer: req.query.redirectURI }); } ]; // The authorization endpoint is the entry point for an OAuth login. // // Each app would start OAuth by redirecting the user to: // // /api/v1/oauth/dialog/authorize?response_type=code&client_id=&redirect_uri=&scope= // // - First, this will ensure the user is logged in. // - Then it will redirect the browser to the given containing the authcode in the query // // -> GET /api/v1/oauth/dialog/authorize var authorization = [ function (req, res, next) { if (!req.query.redirect_uri) return sendErrorPageOrRedirect(req, res, 'Invalid request. redirect_uri query param is not set.'); if (!req.query.client_id) return sendErrorPageOrRedirect(req, res, 'Invalid request. client_id query param is not set.'); if (!req.query.response_type) return sendErrorPageOrRedirect(req, res, 'Invalid request. response_type query param is not set.'); if (req.query.response_type !== 'code' && req.query.response_type !== 'token') return sendErrorPageOrRedirect(req, res, 'Invalid request. Only token and code response types are supported.'); session.ensureLoggedIn('/api/v1/session/login?returnTo=' + req.query.redirect_uri)(req, res, next); }, gServer.authorization({}, function (clientId, redirectURI, callback) { debug('authorization: client %s with callback to %s.', clientId, redirectURI); clients.get(clientId, function (error, client) { if (error && error.reason === ClientsError.NOT_FOUND) return callback(null, false); if (error) return callback(error); // ignore the origin passed into form the client, but use the one from the clientdb var redirectPath = url.parse(redirectURI).path; var redirectOrigin = client.redirectURI; callback(null, client, '/api/v1/session/callback?redirectURI=' + encodeURIComponent(url.resolve(redirectOrigin, redirectPath))); }); }), function (req, res, next) { // Handle our different types of oauth clients var type = req.oauth2.client.type; if (type === clients.TYPE_EXTERNAL || type === clients.TYPE_BUILT_IN) { eventlog.add(eventlog.ACTION_USER_LOGIN, auditSource(req, req.oauth2.client.appId), { userId: req.oauth2.user.id }); return next(); } else if (type === clients.TYPE_SIMPLE_AUTH) { return sendError(req, res, 'Unknown OAuth client.'); } appdb.get(req.oauth2.client.appId, function (error, appObject) { if (error) return sendErrorPageOrRedirect(req, res, 'Invalid request. Unknown app for this client_id.'); apps.hasAccessTo(appObject, req.oauth2.user, function (error, access) { if (error) return sendError(req, res, 'Internal error'); if (!access) return sendErrorPageOrRedirect(req, res, 'No access to this app.'); eventlog.add(eventlog.ACTION_USER_LOGIN, auditSource(req, appObject.id), { userId: req.oauth2.user.id }); next(); }); }); }, gServer.decision({ loadTransaction: false }) ]; // The token endpoint allows an OAuth client to exchange an authcode with an accesstoken. // // Authcodes are obtained using the authorization endpoint. The route is authenticated by // providing a Basic auth with clientID as username and clientSecret as password. // An authcode is only good for one such exchange to an accesstoken. // // -> POST /api/v1/oauth/token var token = [ passport.authenticate(['basic', 'oauth2-client-password'], { session: false }), gServer.token(), gServer.errorHandler() ]; // tests if all requestedScopes are attached to the request function validateRequestedScopes(req, requestedScopes) { assert.strictEqual(typeof req, 'object'); assert(Array.isArray(requestedScopes)); if (!req.authInfo || !req.authInfo.scope) return new Error('No scope found'); var scopes = req.authInfo.scope.split(','); // check for roles separately if (requestedScopes.indexOf(clients.SCOPE_ROLE_SDK) !== -1 && scopes.indexOf(clients.SCOPE_ROLE_SDK) === -1) { return new Error('Missing required scope role "' + clients.SCOPE_ROLE_SDK + '"'); } if (scopes.indexOf('*') !== -1) return null; for (var i = 0; i < requestedScopes.length; ++i) { if (scopes.indexOf(requestedScopes[i]) === -1) { debug('scope: missing scope "%s".', requestedScopes[i]); return new Error('Missing required scope "' + requestedScopes[i] + '"'); } } return null; } // The scope middleware provides an auth middleware for routes. // // It is used for API routes, which are authenticated using accesstokens. // Those accesstokens carry OAuth scopes and the middleware takes the required // scope as an argument and will verify the accesstoken against it. // // See server.js: // var profileScope = routes.oauth2.scope('profile'); // function scope(requestedScope) { assert.strictEqual(typeof requestedScope, 'string'); var requestedScopes = requestedScope.split(','); debug('scope: add routes with requested scopes', requestedScopes); return [ passport.authenticate(['bearer'], { session: false }), function (req, res, next) { var error = validateRequestedScopes(req, requestedScopes); if (error) return next(new HttpError(401, error.message)); next(); } ]; } // Cross-site request forgery protection middleware for login form var csrf = [ middleware.csrf(), function (err, req, res, next) { if (err.code !== 'EBADCSRFTOKEN') return next(err); sendErrorPageOrRedirect(req, res, 'Form expired'); } ]; exports = module.exports = { loginForm: loginForm, login: login, logout: logout, callback: callback, passwordResetRequestSite: passwordResetRequestSite, passwordResetRequest: passwordResetRequest, passwordSentSite: passwordSentSite, passwordResetSite: passwordResetSite, passwordReset: passwordReset, accountSetupSite: accountSetupSite, accountSetup: accountSetup, authorization: authorization, token: token, validateRequestedScopes: validateRequestedScopes, scope: scope, csrf: csrf };