diff --git a/CHANGELOG.md b/CHANGELOG.md index e476313df..ba641a30c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,20 @@ https://github.com/sharetribe/flex-template-web/ ## Upcoming version 2020-XX-XX +## [v6.2.0] 2020-02-18 + +This is update from from [upstream](https://github.com/sharetribe/ftw-daily): v4.2.0 + +- [add] Show a banner when a user is logged in with limited access. + [#1259](https://github.com/sharetribe/ftw-daily/pull/1259) + [#1261](https://github.com/sharetribe/ftw-daily/pull/1261) +- [add] Support for logging in as a user from Console. + [#1254](https://github.com/sharetribe/ftw-daily/pull/1254) +- [change] Add `handlebars` 4.5.3 and `serialize-javascript` 2.1.1 to resolutions in `package.json`. + [#1251](https://github.com/sharetribe/ftw-daily/pull/1251) + +[v6.2.0]: https://github.com/sharetribe/ftw-hourly/compare/v6.1.0...v6.2.0 + ## [v6.1.0] 2020-02-03 Update from upstream (first 3 bullets) and a couple of pending changes. diff --git a/package.json b/package.json index d34f40082..7688ba3ff 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "redux": "^4.0.1", "redux-thunk": "^2.3.0", "seedrandom": "^3.0.3", - "sharetribe-flex-sdk": "^1.8.0", + "sharetribe-flex-sdk": "^1.9.1", "sharetribe-scripts": "3.1.1", "smoothscroll-polyfill": "^0.4.0", "source-map-support": "^0.5.9", @@ -73,7 +73,9 @@ "resolutions": { "react-dates/lodash": "^4.17.14", "react-google-maps/lodash": "^4.17.14", - "react-test-renderer": "^16.9.0" + "react-test-renderer": "^16.9.0", + "handlebars": "^4.5.3", + "serialize-javascript": "^2.1.1" }, "nodemonConfig": { "execMap": { diff --git a/server/apiRouter.js b/server/apiRouter.js new file mode 100644 index 000000000..8e3c6d571 --- /dev/null +++ b/server/apiRouter.js @@ -0,0 +1,156 @@ +/** + * This file contains server side endpoints that can be used to perform backend + * tasks that can not be handled in the browser. + * + * The endpoints should not clash with the application routes. Therefore, the + * endpoints are prefixed in the main server where this file is used. + */ + +const http = require('http'); +const https = require('https'); +const express = require('express'); +const crypto = require('crypto'); +const sharetribeSdk = require('sharetribe-flex-sdk'); +const Decimal = require('decimal.js'); + +const CLIENT_ID = process.env.REACT_APP_SHARETRIBE_SDK_CLIENT_ID; +const ROOT_URL = process.env.REACT_APP_CANONICAL_ROOT_URL; +const CONSOLE_URL = + process.env.SERVER_SHARETRIBE_CONSOLE_URL || 'https://flex-console.sharetribe.com'; +const BASE_URL = process.env.REACT_APP_SHARETRIBE_SDK_BASE_URL; +const TRANSIT_VERBOSE = process.env.REACT_APP_SHARETRIBE_SDK_TRANSIT_VERBOSE === 'true'; +const USING_SSL = process.env.REACT_APP_SHARETRIBE_USING_SSL === 'true'; + +const router = express.Router(); + +// redirect_uri param used when initiating a login as authentication flow and +// when requesting a token using an authorization code +const loginAsRedirectUri = `${ROOT_URL.replace(/\/$/, '')}/api/login-as`; + +// Instantiate HTTP(S) Agents with keepAlive set to true. +// This will reduce the request time for consecutive requests by +// reusing the existing TCP connection, thus eliminating the time used +// for setting up new TCP connections. +const httpAgent = new http.Agent({ keepAlive: true }); +const httpsAgent = new https.Agent({ keepAlive: true }); + +// Cookies used for authorization code authentication. +const stateKey = `st-${CLIENT_ID}-oauth2State`; +const codeVerifierKey = `st-${CLIENT_ID}-pkceCodeVerifier`; + +/** + * Makes a base64 string URL friendly by + * replacing unaccepted characters. + */ +const urlifyBase64 = base64Str => + base64Str + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + +// Initiates an authorization code authentication flow. This authentication flow +// enables marketplace operators that have an ongoing Console session to log +// into their marketplace as a user of the marketplace. +// +// The authorization code is requested from Console and it is used to request a +// token from the Flex Auth API. +// +// This endpoint will return a 302 to Console which requests the authorization +// code. Console returns a 302 with the code to the `redirect_uri` that is +// passed in this response. The request to the redirect URI is handled with the +// `/login-as` endpoint. +router.get('/initiate-login-as', (req, res) => { + const userId = req.query.user_id; + + if (!userId) { + return res.status(400).send('Missing query parameter: user_id.'); + } + if (!ROOT_URL) { + return res.status(409).send('Marketplace canonical root URL is missing.'); + } + + const state = urlifyBase64(crypto.randomBytes(32).toString('base64')); + const codeVerifier = urlifyBase64(crypto.randomBytes(32).toString('base64')); + const hash = crypto + .createHash('sha256') + .update(codeVerifier) + .digest('base64'); + const codeChallenge = urlifyBase64(hash); + const authorizeServerUrl = `${CONSOLE_URL}/api/authorize-as`; + + const location = `${authorizeServerUrl}?\ +response_type=code&\ +client_id=${CLIENT_ID}&\ +redirect_uri=${loginAsRedirectUri}&\ +user_id=${userId}&\ +state=${state}&\ +code_challenge=${codeChallenge}&\ +code_challenge_method=S256`; + + const cookieOpts = { + maxAge: 1000 * 30, // 30 seconds + secure: USING_SSL, + }; + + res.cookie(stateKey, state, cookieOpts); + res.cookie(codeVerifierKey, codeVerifier, cookieOpts); + return res.redirect(location); +}); + +// Works as the redirect_uri passed in an authorization code request. Receives +// an authorization code and uses that to log in and redirect to the landing +// page. +router.get('/login-as', (req, res) => { + const { code, state, error } = req.query; + const storedState = req.cookies[stateKey]; + + if (state !== storedState) { + return res.status(401).send('Invalid state parameter.'); + } + + if (error) { + return res.status(401).send(`Failed to authorize as a user, error: ${error}.`); + } + + const codeVerifier = req.cookies[codeVerifierKey]; + + // clear state and code verifier cookies + res.clearCookie(stateKey, { secure: USING_SSL }); + res.clearCookie(codeVerifierKey, { secure: USING_SSL }); + + const baseUrl = BASE_URL ? { baseUrl: BASE_URL } : {}; + const tokenStore = sharetribeSdk.tokenStore.expressCookieStore({ + clientId: CLIENT_ID, + req, + res, + secure: USING_SSL, + }); + + const sdk = sharetribeSdk.createInstance({ + transitVerbose: TRANSIT_VERBOSE, + clientId: CLIENT_ID, + httpAgent: httpAgent, + httpsAgent: httpsAgent, + tokenStore, + typeHandlers: [ + { + type: sharetribeSdk.types.BigDecimal, + customType: Decimal, + writer: v => new sharetribeSdk.types.BigDecimal(v.toString()), + reader: v => new Decimal(v.value), + }, + ], + ...baseUrl, + }); + + sdk + .login({ + code, + redirect_uri: loginAsRedirectUri, + code_verifier: codeVerifier, + }) + .then(() => res.redirect('/')) + .catch(() => res.status(401).send('Unable to authenticate as a user')); +}); + +module.exports = router; diff --git a/server/index.js b/server/index.js index f45d5f7b5..bafed3384 100644 --- a/server/index.js +++ b/server/index.js @@ -32,6 +32,7 @@ const sharetribeSdk = require('sharetribe-flex-sdk'); const Decimal = require('decimal.js'); const sitemap = require('express-sitemap'); const auth = require('./auth'); +const apiRouter = require('./apiRouter'); const renderer = require('./renderer'); const dataLoader = require('./dataLoader'); const fs = require('fs'); @@ -132,6 +133,9 @@ if (!dev) { } } +// Server-side routes that do not render the application +app.use('/api', apiRouter); + const noCacheHeaders = { 'Cache-control': 'no-cache, no-store, must-revalidate', }; @@ -210,14 +214,15 @@ app.get('*', (req, res) => { // authentication. const token = tokenStore.getToken(); - const refreshTokenExists = !!token && !!token.refresh_token; - - if (refreshTokenExists) { - // If refresh token exists, we assume that client can handle the situation + const scopes = !!token && token.scopes; + const isAnonymous = !!scopes && scopes.length === 1 && scopes[0] === 'public-read'; + if (isAnonymous) { + res.status(401).send(html); + } else { + // If the token is associated with other than public-read scopes, we + // assume that client can handle the situation // TODO: improve by checking if the token is valid (needs an API call) res.status(200).send(html); - } else { - res.status(401).send(html); } } else if (context.forbidden) { res.status(403).send(html); diff --git a/src/components/LimitedAccessBanner/LimitedAccessBanner.css b/src/components/LimitedAccessBanner/LimitedAccessBanner.css new file mode 100644 index 000000000..3edc8658a --- /dev/null +++ b/src/components/LimitedAccessBanner/LimitedAccessBanner.css @@ -0,0 +1,31 @@ +@import '../../marketplace.css'; + +.root { + background-color: #df492a; + text-align: center; + padding: 10px 20px 9px; +} + +.text { + margin: 0; + display: inline-block; + color: #fff; + font-size: 16px; + margin-bottom: 16px; + line-height: 20px; +} + +.button { + background: #2a3d4b; + margin: 0 16px; + padding: 8px 16px; + border-radius: 4px; + font-size: 14px; + color: #fff; + border: 0; + + &:hover { + text-decoration: none; + background: #364f61; + } +} diff --git a/src/components/LimitedAccessBanner/LimitedAccessBanner.js b/src/components/LimitedAccessBanner/LimitedAccessBanner.js new file mode 100644 index 000000000..51c73ea19 --- /dev/null +++ b/src/components/LimitedAccessBanner/LimitedAccessBanner.js @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { FormattedMessage } from '../../util/reactIntl'; +import { propTypes } from '../../util/types'; +import { Button } from '../../components'; +import { ensureCurrentUser } from '../../util/data'; + +import css from './LimitedAccessBanner.css'; + +// Due to the layout structure, do not render the banner on the following pages +const disabledPages = ['SearchPage']; + +const LimitedAccessBanner = props => { + const { + rootClassName, + className, + isAuthenticated, + authScopes, + currentUser, + onLogout, + currentPage, + } = props; + const classes = classNames(rootClassName || css.root, className); + const user = ensureCurrentUser(currentUser); + + const showBanner = + user.id && + isAuthenticated && + authScopes && + authScopes.length === 1 && + authScopes[0] === 'user:limited' && + !disabledPages.includes(currentPage); + + const { firstName, lastName } = user.attributes.profile; + + return showBanner ? ( +
+

+ +

+ +
+ ) : null; +}; + +LimitedAccessBanner.defaultProps = { + rootClassName: null, + className: null, + currentUser: null, + authScopes: [], + currentPage: null, +}; + +const { array, bool, func, string } = PropTypes; + +LimitedAccessBanner.propTypes = { + rootClassName: string, + className: string, + isAuthenticated: bool.isRequired, + authScopes: array, + currentUser: propTypes.currentUser, + onLogout: func.isRequired, + currentPage: string, +}; + +export default LimitedAccessBanner; diff --git a/src/components/Topbar/Topbar.js b/src/components/Topbar/Topbar.js index e4c1b2e1c..f217b07b9 100644 --- a/src/components/Topbar/Topbar.js +++ b/src/components/Topbar/Topbar.js @@ -1,5 +1,5 @@ import React, { Component } from 'react'; -import { bool, func, number, shape, string } from 'prop-types'; +import { array, bool, func, number, shape, string } from 'prop-types'; import { compose } from 'redux'; import { FormattedMessage, intlShape, injectIntl } from '../../util/reactIntl'; import pickBy from 'lodash/pickBy'; @@ -12,6 +12,7 @@ import { createResourceLocatorString, pathByRouteName } from '../../util/routes' import { propTypes } from '../../util/types'; import { Button, + LimitedAccessBanner, Logo, Modal, ModalMissingInformation, @@ -132,6 +133,7 @@ class TopbarComponent extends Component { mobileRootClassName, mobileClassName, isAuthenticated, + authScopes, authInProgress, currentUser, currentUserHasListings, @@ -191,6 +193,13 @@ class TopbarComponent extends Component { return (
+