diff --git a/package-lock.json b/package-lock.json index 02f7b925..12d7470c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8254,6 +8254,14 @@ "pause": "0.0.1" } }, + "passport-anonymous": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/passport-anonymous/-/passport-anonymous-1.0.1.tgz", + "integrity": "sha1-JB43J07ETft/bK0jS0HEODhrwRc=", + "requires": { + "passport-strategy": "1.x.x" + } + }, "passport-http": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/passport-http/-/passport-http-0.3.0.tgz", diff --git a/package.json b/package.json index de7bbf7a..008b67c4 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "morgan": "1.9.1", "oauth2orize": "1.11.0", "passport": "0.4.0", + "passport-anonymous": "1.0.1", "passport-http": "0.3.0", "passport-http-bearer": "1.0.1", "passport-local": "1.0.0", diff --git a/src/routes/api/alexa/_error_handler.js b/src/alexa/_error_handler.js similarity index 100% rename from src/routes/api/alexa/_error_handler.js rename to src/alexa/_error_handler.js diff --git a/src/routes/api/alexa/_skill_definition.json b/src/alexa/_skill_definition.json similarity index 100% rename from src/routes/api/alexa/_skill_definition.json rename to src/alexa/_skill_definition.json diff --git a/src/routes/api/alexa/_utilities.js b/src/alexa/_utilities.js similarity index 100% rename from src/routes/api/alexa/_utilities.js rename to src/alexa/_utilities.js diff --git a/src/routes/api/alexa/index.js b/src/alexa/index.js similarity index 98% rename from src/routes/api/alexa/index.js rename to src/alexa/index.js index eac0315e..32867087 100644 --- a/src/routes/api/alexa/index.js +++ b/src/alexa/index.js @@ -87,7 +87,7 @@ const skill = Alexa.SkillBuilders.custom() .addErrorHandlers(ErrorHandler) .create(); -export const post = (req, res, next) => { +const post = (req, res, next) => { return new SkillRequestSignatureVerifier() .verify(req.rawBody, req.headers) .then(() => { @@ -104,3 +104,5 @@ export const post = (req, res, next) => { res.status(500); }); }; + +export default post; diff --git a/src/routes/api/alexa/intent_handlers/_cancel_and_stop.js b/src/alexa/intent_handlers/_cancel_and_stop.js similarity index 100% rename from src/routes/api/alexa/intent_handlers/_cancel_and_stop.js rename to src/alexa/intent_handlers/_cancel_and_stop.js diff --git a/src/routes/api/alexa/intent_handlers/_decision_given_choose_story_decision.js b/src/alexa/intent_handlers/_decision_given_choose_story_decision.js similarity index 96% rename from src/routes/api/alexa/intent_handlers/_decision_given_choose_story_decision.js rename to src/alexa/intent_handlers/_decision_given_choose_story_decision.js index 3948eab4..7fcbafdb 100644 --- a/src/routes/api/alexa/intent_handlers/_decision_given_choose_story_decision.js +++ b/src/alexa/intent_handlers/_decision_given_choose_story_decision.js @@ -7,7 +7,7 @@ import { storyDecisionChoices, updateStoryDecisionChoicesDirective, findConfirmedSlotValue -} from "src/routes/api/alexa/_utilities"; +} from "src/alexa/_utilities"; import * as Stories from "src/routes/story/_stories"; import * as History from "src/components/Adventure/history"; @@ -30,6 +30,7 @@ const DecisionGivenChooseStoryDecisionIntentHandler = { const sessionAttributes = handlerInput.attributesManager.getSessionAttributes(); const storyId = sessionAttributes.storyId; + // TODO(kyle): record visitation sessionAttributes.store = History.goToDecision( decision, sessionAttributes.store diff --git a/src/routes/api/alexa/intent_handlers/_go_back.js b/src/alexa/intent_handlers/_go_back.js similarity index 97% rename from src/routes/api/alexa/intent_handlers/_go_back.js rename to src/alexa/intent_handlers/_go_back.js index 014d1c6c..91429605 100644 --- a/src/routes/api/alexa/intent_handlers/_go_back.js +++ b/src/alexa/intent_handlers/_go_back.js @@ -5,7 +5,7 @@ import { asSpeakableStoryText, storyDecisionChoices, updateStoryDecisionChoicesDirective -} from "src/routes/api/alexa/_utilities"; +} from "src/alexa/_utilities"; const GoBackIntentHandler = { canHandle(handlerInput) { diff --git a/src/routes/api/alexa/intent_handlers/_help.js b/src/alexa/intent_handlers/_help.js similarity index 92% rename from src/routes/api/alexa/intent_handlers/_help.js rename to src/alexa/intent_handlers/_help.js index a8b770ee..0330d165 100644 --- a/src/routes/api/alexa/intent_handlers/_help.js +++ b/src/alexa/intent_handlers/_help.js @@ -1,4 +1,4 @@ -import { speechWithSubscriptionPrompt } from "src/routes/api/alexa/_utilities"; +import { speechWithSubscriptionPrompt } from "src/alexa/_utilities"; const HelpIntentHandler = { canHandle(handlerInput) { diff --git a/src/routes/api/alexa/intent_handlers/_restart_story.js b/src/alexa/intent_handlers/_restart_story.js similarity index 89% rename from src/routes/api/alexa/intent_handlers/_restart_story.js rename to src/alexa/intent_handlers/_restart_story.js index 03e92b3f..bbf605a7 100644 --- a/src/routes/api/alexa/intent_handlers/_restart_story.js +++ b/src/alexa/intent_handlers/_restart_story.js @@ -1,7 +1,5 @@ import * as Stories from "src/routes/story/_stories"; -import { - startFreshStory -} from "src/routes/api/alexa/_utilities"; +import { startFreshStory } from "src/alexa/_utilities"; const RestartStoryIntentHandler = { name: "RestartStoryIntentHandler", diff --git a/src/routes/api/alexa/intent_handlers/_started_in_progress_choose_story.js b/src/alexa/intent_handlers/_started_in_progress_choose_story.js similarity index 87% rename from src/routes/api/alexa/intent_handlers/_started_in_progress_choose_story.js rename to src/alexa/intent_handlers/_started_in_progress_choose_story.js index 63f8feea..b0c1803e 100644 --- a/src/routes/api/alexa/intent_handlers/_started_in_progress_choose_story.js +++ b/src/alexa/intent_handlers/_started_in_progress_choose_story.js @@ -1,4 +1,4 @@ -import { listStoriesForAlexa } from "src/routes/api/alexa/_utilities"; +import { listStoriesForAlexa } from "src/alexa/_utilities"; import { logger } from "src/logging"; const StartedInProgressChooseStoryIntentHandler = { diff --git a/src/routes/api/alexa/intent_handlers/_story_title_choice_given_choose_story.js b/src/alexa/intent_handlers/_story_title_choice_given_choose_story.js similarity index 88% rename from src/routes/api/alexa/intent_handlers/_story_title_choice_given_choose_story.js rename to src/alexa/intent_handlers/_story_title_choice_given_choose_story.js index 1c27f328..f90b1591 100644 --- a/src/routes/api/alexa/intent_handlers/_story_title_choice_given_choose_story.js +++ b/src/alexa/intent_handlers/_story_title_choice_given_choose_story.js @@ -1,7 +1,4 @@ -import { - startFreshStory, - findConfirmedSlotValue -} from "src/routes/api/alexa/_utilities"; +import { startFreshStory, findConfirmedSlotValue } from "src/alexa/_utilities"; import * as Stories from "src/routes/story/_stories.js"; const StoryTitleChoiceGivenChooseStoryIntentHandler = { diff --git a/src/routes/api/alexa/request_handlers/_launch.js b/src/alexa/request_handlers/_launch.js similarity index 100% rename from src/routes/api/alexa/request_handlers/_launch.js rename to src/alexa/request_handlers/_launch.js diff --git a/src/routes/api/alexa/request_handlers/_session_ended.js b/src/alexa/request_handlers/_session_ended.js similarity index 100% rename from src/routes/api/alexa/request_handlers/_session_ended.js rename to src/alexa/request_handlers/_session_ended.js diff --git a/src/authentication/index.js b/src/authentication/index.js index 6259f76e..01e6e46b 100644 --- a/src/authentication/index.js +++ b/src/authentication/index.js @@ -2,6 +2,7 @@ import config from "config"; import passport from "passport"; import passportLocal from "passport-local"; import securePassword from "secure-password"; +import { Strategy as AnonymousStrategy } from "passport-anonymous"; import { BasicStrategy } from "passport-http"; import { ensureLoggedIn } from "connect-ensure-login"; import { findUser, findUserSafeDetails } from "src/lib/server/users"; @@ -45,6 +46,9 @@ const compare = (userPassword, hash) => { }); }; +// TODO(kyle): Should be able to configure actual routes that don't need auth or +// such using a real express setup in server.js, now that we know how Sapper works. +// Kind of. Probably need one "optional" auth middleware wrapping usual passport.authenticate const noAuthRoutes = [ "/", "/about", @@ -73,7 +77,6 @@ const noAuthPrefixes = [ "/?", "/api/user/login", "/api/user/new", - "/api/alexa", "/client", "/error", "/experimental", @@ -82,10 +85,10 @@ const noAuthPrefixes = [ "/story" ]; -const requiresAuth = req => { +const routeRequiresAuth = req => { return ( - noAuthRoutes.includes(req.path) || - noAuthPrefixes.some(prefix => req.path.startsWith(prefix)) + !noAuthRoutes.includes(req.path) && + !noAuthPrefixes.some(prefix => req.path.startsWith(prefix)) ); }; @@ -93,20 +96,30 @@ const requiresAuth = req => { * Protects our routes based on some rules. * You get a redirect for bad HTML requests and a 401 for API-esque JS or JSON requests. */ -const protectNonDefaultRoutes = (req, res, next) => { - if (req.isAuthenticated()) next(); - else if (requiresAuth(req)) next(); - else { - logger.info({ url: req.url }, "Unauthenticated access found on url"); - - if (req.path.endsWith(".js") || req.path.endsWith(".json")) { - res.writeHead(401); - } else { - return ensureLoggedIn("/user/login")(req, res, next); +const authenticateNonDefaultRoutes = (req, res, next) => { + passport.authenticate("local", (err, user, info) => { + if (user) { + return req.logIn(user, function(err) { + if (err) { + return next(err); + } + next(); + }); } - res.end(); - return; - } + + if (routeRequiresAuth(req)) { + if (req.path.endsWith(".js") || req.path.endsWith(".json")) { + res.writeHead(401); + } else { + return ensureLoggedIn("/user/login")(req, res, next); + } + res.end(); + return; + } + + // No user and no auth required + next(); + })(req, res, next); }; /** @@ -162,6 +175,8 @@ export const initPassport = () => { passport.use(tokenStrategy); + passport.use(new AnonymousStrategy()); + // We only do this for Alexa OAUTH. passport.use( new BasicStrategy(function(userId, secret, done) { @@ -175,7 +190,7 @@ export const initPassport = () => { }) ); - passport.authenticationMiddleware = () => protectNonDefaultRoutes; + passport.authenticateNonDefaultRoutes = () => authenticateNonDefaultRoutes; }; export { passport }; diff --git a/src/authentication/oauth.js b/src/authentication/oauth.js index 7a755f57..c1f59226 100644 --- a/src/authentication/oauth.js +++ b/src/authentication/oauth.js @@ -5,10 +5,11 @@ import securePassword from "secure-password"; import uuidv4 from "uuid/v4"; import { passport } from "src/authentication"; import { findUser, findUserSafeDetails } from "src/lib/server/users"; -import { logger } from "src/logging"; +import { logger, logResponse } from "src/logging"; import { pool } from "src/lib/server/database.js"; -import { join, times } from "lodash"; +import { join, times, includes } from "lodash"; +// TODO(kyle): create a route for users to delete tokens const tokenHasher = securePassword(); export const server = oauth2orize.createServer(); @@ -17,6 +18,10 @@ const findAuthorizationCode = async (code, done) => { try { const results = await pool.query( ` + DELETE * + FROM oauth_authorization_codes + WHERE created < CURRENT_DATE - INTERVAL '30 minutes'; + SELECT client_id AS clientId, redirect_uri AS redirectUri, @@ -24,7 +29,7 @@ const findAuthorizationCode = async (code, done) => { FROM oauth_authorization_codes WHERE - code = $1 + code = $1; `, [code] ); @@ -67,6 +72,10 @@ const findAccessToken = async (token, done) => { try { const results = await pool.query( ` + DELETE * + FROM oauth_access_tokens + WHERE created < CURRENT_DATE - INTERVAL '1 year'; + SELECT client_id AS clientId, user_id AS userId @@ -123,19 +132,15 @@ server.grant( ); server.serializeClient((client, done) => { + logger.info(client, "Serializing oauth client"); done(null, client.clientId); }); -server.deserializeClient(async (id, done) => { - try { - const details = await findUserSafeDetails(id); - - if (!details) return done(null, false); - - return done(null, details); - } catch (error) { - return done(error); - } +server.deserializeClient((clientId, done) => { + logger.info({ clientId }, "Deserializing oauth client"); + // We only have one client right now, so this is pretty dumb + if (clientId !== config.get("alexa.clientId")) return done(null, false); + return done(null, { clientId, isTrusted: true }); }); server.exchange( @@ -221,6 +226,7 @@ const findAccessTokenByUserIdAndClientId = async (userId, clientId, done) => { }; export const authorize = [ + logResponse, server.authorize( (clientId, redirectUri, done) => { if ( diff --git a/src/lib/utilities.js b/src/lib/utilities.js index 7c2608ca..535b62c9 100644 --- a/src/lib/utilities.js +++ b/src/lib/utilities.js @@ -1,11 +1,11 @@ -import { take, takeRight, set, clone, omit } from 'lodash'; +import { take, takeRight, set, clone, omit } from "lodash"; -export const dropIdx = (coll, idx) => ([ +export const dropIdx = (coll, idx) => [ ...take(coll, idx), - ...takeRight(coll, (coll.length - idx - 1)), -]); + ...takeRight(coll, coll.length - idx - 1) +]; export const renameKey = (object, key, newKey) => ({ - ...omit(object, [key]), - [newKey]: object[key] -}) + ...omit(object, [key]), + [newKey]: object[key] +}); diff --git a/src/logging.js b/src/logging.js index 58eb5d99..5f1a8119 100644 --- a/src/logging.js +++ b/src/logging.js @@ -5,3 +5,39 @@ import morgan from "morgan"; export const logger = bunyan.createLogger({ name: "browser" }); export const requestLoggingMiddleware = morgan(config.get("logging.format")); + +export const logResponse = (req, res, next) => { + const oldWrite = res.write; + const oldEnd = res.end; + + const chunks = []; + + res.write = (...restArgs) => { + chunks.push(Buffer.from(restArgs[0])); + oldWrite.apply(res, restArgs); + }; + + res.end = (...restArgs) => { + if (restArgs[0]) { + chunks.push(Buffer.from(restArgs[0])); + } + const body = Buffer.concat(chunks).toString("utf8"); + + logger.info({ + time: new Date().toUTCString(), + fromIP: req.headers["x-forwarded-for"] || req.connection.remoteAddress, + method: req.method, + originalUri: req.originalUrl, + uri: req.url, + headers: res.getHeaders(), + requestData: req.body, + responseData: body, + referer: req.headers.referer || "" + }); + + // console.log(body); + oldEnd.apply(res, restArgs); + }; + + next(); +}; diff --git a/src/routes/api/user/login.js b/src/routes/api/user/login.js index 4c265ee2..5c5c214c 100644 --- a/src/routes/api/user/login.js +++ b/src/routes/api/user/login.js @@ -15,8 +15,11 @@ const post = (req, res, next) => { if (err) { return next(err); } + logger.info( + { returnTo: req.session.returnTo, userId: user.id }, + "User logged in succesfully" + ); const redirect = req.session.returnTo || "/"; - logger.info({ redirect, userId: user.id }, "User logged in succesfully"); return findUserSafeDetails(user.id).then(user => res.redirect(redirect)); }); })(req, res, next); diff --git a/src/routes/oauth/authorize/dialog.svelte b/src/routes/oauth/authorize/dialog.svelte index c65e4912..36a7016c 100644 --- a/src/routes/oauth/authorize/dialog.svelte +++ b/src/routes/oauth/authorize/dialog.svelte @@ -49,7 +49,7 @@ type="hidden" value="{$page.query.transactionId}" /> - + diff --git a/src/server.js b/src/server.js index 3fccb73c..49e9bab2 100644 --- a/src/server.js +++ b/src/server.js @@ -1,12 +1,14 @@ +import * as oauth from "src/authentication/oauth"; import * as sapper from "@sapper/server"; +import alexaPost from "src/alexa"; import bodyParser from "body-parser"; import compression from "compression"; import config from "config"; import cookieParser from "cookie-parser"; import express from "express"; +import fs from "fs"; import helmet from "helmet"; import https from "https"; -import fs from "fs"; import pgSession from "connect-pg-simple"; import securePassword from "secure-password"; import session from "express-session"; @@ -16,9 +18,8 @@ import { Store } from "svelte/store"; import { Strategy as LocalStrategy } from "passport-local"; import { csrfProtection, exposeCsrfMiddleware } from "src/lib/server/csrf"; import { exposeStripeKeyMiddleware } from "src/lib/server/stripe"; +import { logger, logResponse, requestLoggingMiddleware } from "./logging"; import { passport, initPassport } from "src/authentication"; -import * as oauth from "src/authentication/oauth"; -import { logger, requestLoggingMiddleware } from "./logging"; import { pool } from "src/lib/server/database.js"; import { requireHttps } from "src/lib/server/require_https"; import { requireRoot } from "src/lib/server/require_root"; @@ -52,8 +53,7 @@ const middleware = [ bodyParser.json({ verify: (req, res, buf) => { // Sometimes (e.g. Stripe hook routes) we'll want the raw body. So we'll save it. - // TODO(kyle): Properly done we would split our express routes up a bit. - if (req.path.includes("api")) req.rawBody = buf; + req.rawBody = buf; } }), requestLoggingMiddleware, @@ -85,9 +85,6 @@ const middleware = [ secure: !config.get("dev") || config.get("server.enableHttps") } }), - csrfProtection, - exposeCsrfMiddleware, - exposeStripeKeyMiddleware, nonce, helmet.contentSecurityPolicy({ reportOnly: false, @@ -128,20 +125,29 @@ app.use(...middleware); app.get("/oauth/authorize", oauth.authorize); -app.post("/oauth/authorize/decision", oauth.server.decision()); +app.post("/oauth/authorize/decision", logResponse, oauth.server.decision()); -app.get( +app.post( "/oauth/token", - passport.authenticate(["basic"], { + passport.authenticate(["basic", "oauth2-client-password"], { session: false }), oauth.server.token(), oauth.server.errorHandler() ); +app.post( + "/alexa", + passport.authenticate(["bearer", "anonymous"], { session: false }), + alexaPost +); + app.use( "/", - passport.authenticationMiddleware(), + csrfProtection, + exposeCsrfMiddleware, + exposeStripeKeyMiddleware, + passport.authenticateNonDefaultRoutes(), sapper.middleware({ session: req => ({ user: req.user