diff --git a/Dockerfile b/Dockerfile index 5ec313911..f4783f4e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,8 @@ ENV CONFIG_VARS sqlalchemy.url sqlalchemy.pool_recycle sqlalchemy.pool_size sqla checker check_collector default_max_age package srid \ reset_password fulltextsearch global_headers headers authorized_referers hooks stats db_chooser \ dbsessions urllogin host_forward_host smtp c2c.base_path welcome_email \ - lingua_extractor interfaces_config interfaces devserver_url api authentication intranet metrics + lingua_extractor interfaces_config interfaces devserver_url api authentication intranet metrics \ + duo_web COPY . /tmp/config/ diff --git a/docker-compose.yaml b/docker-compose.yaml index 624b99354..b9e46d7bf 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -72,7 +72,6 @@ services: extends: file: docker-compose-lib.yaml service: geoportal - image: camptocamp/geomapfish-geoportal:2.5 volumes_from: - config:ro environment: diff --git a/geoportal/.eslintignore b/geoportal/.eslintignore new file mode 100644 index 000000000..fea481feb --- /dev/null +++ b/geoportal/.eslintignore @@ -0,0 +1 @@ +/geomapfish_geoportal/static-ngeo/js/apps/duo/Duo-Web-v2.js diff --git a/geoportal/.eslintrc b/geoportal/.eslintrc index a143fda9a..3968a7964 100644 --- a/geoportal/.eslintrc +++ b/geoportal/.eslintrc @@ -1,6 +1,7 @@ extends: - openlayers globals: + 'Duo': false 'geomapfish': false env: jquery: true diff --git a/geoportal/geomapfish_geoportal/__init__.py b/geoportal/geomapfish_geoportal/__init__.py index 836a75376..ca6c77632 100644 --- a/geoportal/geomapfish_geoportal/__init__.py +++ b/geoportal/geomapfish_geoportal/__init__.py @@ -3,9 +3,9 @@ import distutils.core from pyramid.config import Configurator from c2cgeoportal_geoportal import locale_negotiator, add_interface, INTERFACE_TYPE_NGEO -from c2cgeoportal_geoportal.lib.authentication import create_authentication from geomapfish_geoportal.resources import Root +from geomapfish_geoportal.duoweb import create_authentication def main(global_config, **settings): """ @@ -24,6 +24,8 @@ def main(global_config, **settings): config.include('c2cgeoportal_geoportal') distutils.core._setup_stop_after = None + config.include('geomapfish_geoportal.duoweb') + config.add_translation_dirs('geomapfish_geoportal:locale/') # Scan view decorator for adding routes diff --git a/geoportal/geomapfish_geoportal/duoweb.py b/geoportal/geomapfish_geoportal/duoweb.py new file mode 100644 index 000000000..ba4aec011 --- /dev/null +++ b/geoportal/geomapfish_geoportal/duoweb.py @@ -0,0 +1,93 @@ +# FIXME: is_password_changed +# FIXME: consecutive_failed +# FIXME: update_last_login + +import logging + +from pyramid.view import view_config +from pyramid.authentication import AuthTktAuthenticationPolicy +from pyramid.security import remember +from pyramid.httpexceptions import HTTPBadRequest, HTTPFound, HTTPUnauthorized + +from c2cgeoportal_geoportal.resources import defaultgroupsfinder + +from duo_web import sign_request, verify_response + + +LOG = logging.getLogger(__name__) +logging.basicConfig(level=10) + +def includeme(config): + config.add_route('login', '/login') + config.add_view(login, route_name='login') + config.add_route('duoweb_post_action', '/duoweb/post_action') + config.add_view(duoweb_post_action, route_name='duoweb_post_action') + + +def create_authentication(settings): + timeout = settings.get("authtkt_timeout") + timeout = None if timeout is None or timeout.lower() == "none" else int(timeout) + reissue_time = settings.get("authtkt_reissue_time") + reissue_time = None if reissue_time is None or reissue_time.lower() == "none" else int(reissue_time) + max_age = settings.get("authtkt_max_age") + max_age = None if max_age is None or max_age.lower() == "none" else int(max_age) + http_only = settings.get("authtkt_http_only", "True") + http_only = http_only.lower() in ("true", "yes", "1") + secure = settings.get("authtkt_secure", "True") + secure = secure.lower() in ("true", "yes", "1") + samesite = settings.get("authtkt_samesite", "Lax") + secret = settings.get("authtkt_secret") + return DuoWebAuthenticationPolicy( + secret, + callback=defaultgroupsfinder, + cookie_name=settings["authtkt_cookie_name"], + samesite=None if samesite == "" else samesite, + timeout=timeout, + max_age=timeout, + reissue_time=reissue_time, + hashalg="sha512", + http_only=http_only, + secure=secure, + ) + +class DuoWebAuthenticationPolicy(AuthTktAuthenticationPolicy): + def authenticated_userid(self, request): + # FIXME: necessary ? + userid = self.unauthenticated_userid(request) + LOG.info('authenticated_userid: %s' % userid) + if userid is not None: + return userid + # else: + # # back from DuoWeb, validate response + # sig_response = request.params.get('sig_response') + # if sig_response is not None: + # return verify_response(ikey, skey, akey, sig_response) + +# FIXME: 'duoweb_login' instead of 'login' ? +@view_config(route_name='login', renderer='json') +def login(request): + login = request.params.get("login") + password = request.params.get("password") + if login is None or password is None: + raise HTTPBadRequest() + username = request.registry.validate_user(request, login, password) + if username is None: + raise HTTPUnauthorized() + + config = request.registry.settings.get('duo_web') + return { + 'sig_request': sign_request(**config, username=username), + # 'c2cjufr': sign_request(ikey, skey, akey, 'c2cjufr') + } + + +@view_config(route_name='duoweb_post_action', renderer='json') +def duoweb_post_action(request): + sig_response = request.params.get('sig_response') + config = request.registry.settings.get('duo_web') + authenticated_username = verify_response(**config, sig_response=sig_response) + if authenticated_username is not None: + headers = remember(request, authenticated_username) + return HTTPFound(request.route_url('loginuser'), headers=headers) + else: + raise HTTPUnauthorized() diff --git a/geoportal/geomapfish_geoportal/static-ngeo/js/apps/desktop.html.ejs b/geoportal/geomapfish_geoportal/static-ngeo/js/apps/desktop.html.ejs index b693c88af..2da393854 100644 --- a/geoportal/geomapfish_geoportal/static-ngeo/js/apps/desktop.html.ejs +++ b/geoportal/geomapfish_geoportal/static-ngeo/js/apps/desktop.html.ejs @@ -119,6 +119,7 @@ +
diff --git a/geoportal/geomapfish_geoportal/static-ngeo/js/apps/duo/Duo-Web-v2.js b/geoportal/geomapfish_geoportal/static-ngeo/js/apps/duo/Duo-Web-v2.js new file mode 100644 index 000000000..3624c87c8 --- /dev/null +++ b/geoportal/geomapfish_geoportal/static-ngeo/js/apps/duo/Duo-Web-v2.js @@ -0,0 +1,578 @@ +/** + * Duo Web SDK v2 + * Copyright 2019, Duo Security + */ + +(function (root, factory) { + /*eslint-disable */ + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define([], factory); + /*eslint-enable */ + } else if (typeof module === 'object' && module.exports) { + // Node. Does not work with strict CommonJS, but + // only CommonJS-like environments that support module.exports, + // like Node. + module.exports = factory(); + } else { + // Browser globals (root is window) + var Duo = factory(); + // If the Javascript was loaded via a script tag, attempt to autoload + // the frame. + Duo._onReady(Duo.init); + + // Attach Duo to the `window` object + root.Duo = Duo; + } +}(this, function() { + var DUO_MESSAGE_FORMAT = /^(?:AUTH|ENROLL)+\|[A-Za-z0-9\+\/=]+\|[A-Za-z0-9\+\/=]+$/; + var DUO_ERROR_FORMAT = /^ERR\|[\w\s\.\(\)]+$/; + var DUO_OPEN_WINDOW_FORMAT = /^DUO_OPEN_WINDOW\|/; + var VALID_OPEN_WINDOW_DOMAINS = [ + 'duo.com', + 'duosecurity.com', + 'duomobile.s3-us-west-1.amazonaws.com' + ]; + + var postAction, + postArgument, + host, + sigRequest, + duoSig, + appSig, + iframe, + submitCallback; + + // We use this function instead of setting initial values in the var + // declarations to make sure the initial values and subsequent + // re-initializations are always the same. + initializeStatefulVariables(); + + /** + * Set local variables to whatever they should be before you call init(). + */ + function initializeStatefulVariables() { + postAction = ''; + postArgument = 'sig_response'; + host = undefined; + sigRequest = undefined; + duoSig = undefined; + appSig = undefined; + iframe = undefined; + submitCallback = undefined; + } + + function throwError(message, givenUrl) { + var url = ( + givenUrl || + 'https://www.duosecurity.com/docs/duoweb#3.-show-the-iframe' + ); + throw new Error( + 'Duo Web SDK error: ' + message + + (url ? ('\n' + 'See ' + url + ' for more information') : '') + ); + } + + function hyphenize(str) { + return str.replace(/([a-z])([A-Z])/, '$1-$2').toLowerCase(); + } + + // cross-browser data attributes + function getDataAttribute(element, name) { + if ('dataset' in element) { + return element.dataset[name]; + } else { + return element.getAttribute('data-' + hyphenize(name)); + } + } + + // cross-browser event binding/unbinding + function on(context, event, fallbackEvent, callback) { + if ('addEventListener' in window) { + context.addEventListener(event, callback, false); + } else { + context.attachEvent(fallbackEvent, callback); + } + } + + function off(context, event, fallbackEvent, callback) { + if ('removeEventListener' in window) { + context.removeEventListener(event, callback, false); + } else { + context.detachEvent(fallbackEvent, callback); + } + } + + function onReady(callback) { + on(document, 'DOMContentLoaded', 'onreadystatechange', callback); + } + + function offReady(callback) { + off(document, 'DOMContentLoaded', 'onreadystatechange', callback); + } + + function onMessage(callback) { + on(window, 'message', 'onmessage', callback); + } + + function offMessage(callback) { + off(window, 'message', 'onmessage', callback); + } + + /** + * Parse the sig_request parameter, throwing errors if the token contains + * a server error or if the token is invalid. + * + * @param {String} sig Request token + */ + function parseSigRequest(sig) { + if (!sig) { + // nothing to do + return; + } + + // see if the token contains an error, throwing it if it does + if (sig.indexOf('ERR|') === 0) { + throwError(sig.split('|')[1]); + } + + // validate the token + if (sig.indexOf(':') === -1 || sig.split(':').length !== 2) { + throwError( + 'Duo was given a bad token. This might indicate a configuration ' + + 'problem with one of Duo\'s client libraries.' + ); + } + + var sigParts = sig.split(':'); + + // hang on to the token, and the parsed duo and app sigs + sigRequest = sig; + duoSig = sigParts[0]; + appSig = sigParts[1]; + + return { + sigRequest: sig, + duoSig: sigParts[0], + appSig: sigParts[1] + }; + } + + /** + * Validate that a MessageEvent came from the Duo service, and that it + * is a properly formatted payload. + * + * The Google Chrome sign-in page injects some JS into pages that also + * make use of postMessage, so we need to do additional validation above + * and beyond the origin. + * + * @param {MessageEvent} event Message received via postMessage + */ + function isDuoMessage(event) { + return Boolean( + event.origin === ('https://' + host) && + typeof event.data === 'string' && + ( + event.data.match(DUO_MESSAGE_FORMAT) || + event.data.match(DUO_ERROR_FORMAT) || + event.data.match(DUO_OPEN_WINDOW_FORMAT) + ) + ); + } + + /** + * Validate the request token and prepare for the iframe to become ready. + * + * All options below can be passed into an options hash to `Duo.init`, or + * specified on the iframe using `data-` attributes. + * + * Options specified using the options hash will take precedence over + * `data-` attributes. + * + * Example using options hash: + * ```javascript + * Duo.init({ + * iframe: "some_other_id", + * host: "api-main.duo.test", + * sig_request: "...", + * post_action: "/auth", + * post_argument: "resp" + * }); + * ``` + * + * Example using `data-` attributes: + * ```html + * + * ``` + * + * Some browsers (especially embedded browsers) don't like it when the Duo + * Web SDK changes the `src` attribute on the iframe. To prevent this, there + * is an alternative way to use the Duo Web SDK: + * + * Add a div (or any other container element) instead of an iframe to the + * DOM with an id of "duo_iframe", or pass that element to the + * `iframeContainer` parameter of `Duo.init`. An iframe will be created and + * inserted into that container element, preventing `src` change related + * bugs. WARNING: All other elements in the container will be deleted. + * + * The `iframeAttributes` parameter of `Duo.init` is available to set any + * attributes on the inserted iframe if the Duo Web SDK is inserting the + * iframe. For details, see the parameter documentation below. + * + * @param {Object} options + * @param {String} options.host - Hostname for the Duo Prompt. + * @param {String} options.sig_request - Request token. + * @param {String|HTMLElement} [options.iframe] - The iframe, or id of an + * iframe that will be used for the Duo Prompt. If you don't provide + * this or the `iframeContainer` parameter the Duo Web SDK will default + * to using whatever element has an id of "duo_iframe". + * @param {String|HTMLElement} [options.iframeContainer] - The element you + * want the Duo Prompt inserted into, or the id of that element. + * Anything inside this element will be deleted and replaced with an + * iframe hosting the Duo prompt. If you don't provide this or the + * `iframe` parameter the Duo Web SDK will default to using whatever + * element has an id of "duo_iframe". + * @param {Object} [options.iframeAttributes] - Object with names and + * values coresponding to attributes you want added to the Duo Prompt + * iframe, like `title`, `width` and `allow`. WARNING: this parameter + * only works if you use the `iframeContainer` parameter or add an id + * of "duo_iframe" to an element that isn't an iframe. If you have + * added an iframe to the DOM yourself, you should set those attributes + * directly on the iframe. + * @param {String} [options.post_action=''] - URL to POST back to after a + * successful auth. + * @param {String} [options.post_argument='sig_response'] - Parameter name + * to use for response token. + * @param {Function} [options.submit_callback] - If provided, the Duo Web + * SDK will not submit the form. Instead it will execute this callback + * function passing in a reference to the "duo_form" form object. + * `submit_callback`` can be used to prevent the webpage from reloading. + */ + function init(options) { + // If init() is called more than once we have to reset all the local + // variables to ensure init() will work the same way every time. This + // helps people making single page applications. SPAs may periodically + // remove the iframe and add a new one that has to be initialized. + initializeStatefulVariables(); + + if (options) { + if (options.host) { + host = options.host; + } + + if (options.sig_request) { + parseSigRequest(options.sig_request); + } + + if (options.post_action) { + postAction = options.post_action; + } + + if (options.post_argument) { + postArgument = options.post_argument; + } + + if (typeof options.submit_callback === 'function') { + submitCallback = options.submit_callback; + } + } + + var promptElement = getPromptElement(options); + if (promptElement) { + // If we can get the element that will host the prompt, set it. + ready(promptElement, options.iframeAttributes || {}); + } else { + // If the element that will host the prompt isn't available yet, set + // it up after the DOM finishes loading. + asyncReady(options); + } + + // always clean up after yourself! + offReady(init); + } + + /** + * Given the options from init(), get the iframe or iframe container that + * should be used for the Duo Prompt. Returns `null` if nothing was found. + */ + function getPromptElement(options) { + var result; + + if (options.iframe && options.iframeContainer) { + throwError( + 'Passing both `iframe` and `iframeContainer` arguments at the' + + ' same time is not allowed.' + ); + } else if (options.iframe) { + // If we are getting an iframe, try to get it and raise if the + // element we find is NOT an iframe. + result = getUserDefinedElement(options.iframe); + validateIframe(result); + } else if (options.iframeContainer) { + result = getUserDefinedElement(options.iframeContainer); + validateIframeContainer(result); + } else { + result = document.getElementById('duo_iframe'); + } + + return result; + } + + /** + * When given an HTMLElement, return it. When given a string, get an element + * with that id, else return null. + */ + function getUserDefinedElement(object) { + if (object.tagName) { + return object; + } else if (typeof object == 'string') { + return document.getElementById(object); + } + return null; + } + + /** + * Check if the given thing is an iframe. + */ + function isIframe(element) { + return ( + element && + element.tagName && + element.tagName.toLowerCase() === 'iframe' + ); + } + + /** + * Throw an error if we are given an element that is NOT an iframe. + */ + function validateIframe(element) { + if (element && !isIframe(element)) { + throwError( + '`iframe` only accepts an iframe element or the id of an' + + ' iframe. To use a non-iframe element, use the' + + ' `iframeContainer` argument.' + ); + } + } + + /** + * Throw an error if we are given an element that IS an iframe instead of an + * element that we can insert an iframe into. + */ + function validateIframeContainer(element) { + if (element && isIframe(element)) { + throwError( + '`iframeContainer` only accepts a non-iframe element or the' + + ' id of a non-iframe. To use a non-iframe element, use the' + + ' `iframeContainer` argument on Duo.init().' + ); + } + } + + /** + * Generate the URL that goes to the Duo Prompt. + */ + function generateIframeSrc() { + return [ + 'https://', host, '/frame/web/v1/auth?tx=', duoSig, + '&parent=', encodeURIComponent(document.location.href), + '&v=2.8' + ].join(''); + } + + /** + * This function is called when a message was received from another domain + * using the `postMessage` API. Check that the event came from the Duo + * service domain, and that the message is a properly formatted payload, + * then perform the post back to the primary service. + * + * @param event Event object (contains origin and data) + */ + function onReceivedMessage(event) { + if (isDuoMessage(event)) { + if (event.data.match(DUO_OPEN_WINDOW_FORMAT)) { + var url = event.data.substring("DUO_OPEN_WINDOW|".length); + if (isValidUrlToOpen(url)) { + // Open the URL that comes after the DUO_WINDOW_OPEN token. + window.open(url, "_self"); + } + } + else { + // the event came from duo, do the post back + doPostBack(event.data); + + // always clean up after yourself! + offMessage(onReceivedMessage); + } + } + } + + /** + * Validate that this passed in URL is one that we will actually allow to + * be opened. + * @param url String URL that the message poster wants to open + * @returns {boolean} true if we allow this url to be opened in the window + */ + function isValidUrlToOpen(url) { + if (!url) { + return false; + } + + var parser = document.createElement('a'); + parser.href = url; + + if (parser.protocol === "duotrustedendpoints:") { + return true; + } else if (parser.protocol !== "https:") { + return false; + } + + for (var i = 0; i < VALID_OPEN_WINDOW_DOMAINS.length; i++) { + if (parser.hostname.endsWith("." + VALID_OPEN_WINDOW_DOMAINS[i]) || + parser.hostname === VALID_OPEN_WINDOW_DOMAINS[i]) { + return true; + } + } + return false; + } + + /** + * Register a callback to call ready() after the DOM has loaded. + */ + function asyncReady(options) { + var callback = function() { + var promptElement = getPromptElement(options); + if (!promptElement) { + throwError( + 'This page does not contain an iframe for Duo to use.' + + ' Add an element like' + + ' to this page.' + ); + } + + ready(promptElement, options.iframeAttributes || {}); + + // Always clean up after yourself. + offReady(callback) + }; + + onReady(callback); + } + + /** + * Point the iframe at Duo, then wait for it to postMessage back to us. + */ + function ready(promptElement, iframeAttributes) { + if (!host) { + host = getDataAttribute(promptElement, 'host'); + + if (!host) { + throwError( + 'No API hostname is given for Duo to use. Be sure to pass ' + + 'a `host` parameter to Duo.init, or through the `data-host` ' + + 'attribute on the iframe element.' + ); + } + } + + if (!duoSig || !appSig) { + parseSigRequest(getDataAttribute(promptElement, 'sigRequest')); + + if (!duoSig || !appSig) { + throwError( + 'No valid signed request is given. Be sure to give the ' + + '`sig_request` parameter to Duo.init, or use the ' + + '`data-sig-request` attribute on the iframe element.' + ); + } + } + + // if postAction/Argument are defaults, see if they are specified + // as data attributes on the iframe + if (postAction === '') { + postAction = getDataAttribute(promptElement, 'postAction') || postAction; + } + + if (postArgument === 'sig_response') { + postArgument = getDataAttribute(promptElement, 'postArgument') || postArgument; + } + + if (isIframe(promptElement)) { + iframe = promptElement; + iframe.src = generateIframeSrc(); + } else { + // If given a container to put an iframe in, clean out any children + // child elements in case `init()` was called more than once. + while (promptElement.firstChild) { + // We call `removeChild()` instead of doing `innerHTML = ""` + // to make sure we unbind any events. + promptElement.removeChild(promptElement.firstChild) + } + + iframe = document.createElement('iframe'); + + // Set the src and all other attributes on the new iframe. + iframeAttributes['src'] = generateIframeSrc(); + for (var name in iframeAttributes) { + iframe.setAttribute(name, iframeAttributes[name]); + } + + promptElement.appendChild(iframe); + } + + // listen for the 'message' event + onMessage(onReceivedMessage); + } + + /** + * We received a postMessage from Duo. POST back to the primary service + * with the response token, and any additional user-supplied parameters + * given in form#duo_form. + */ + function doPostBack(response) { + // create a hidden input to contain the response token + var input = document.createElement('input'); + input.type = 'hidden'; + input.name = postArgument; + input.value = response + ':' + appSig; + + // user may supply their own form with additional inputs + var form = document.getElementById('duo_form'); + + // if the form doesn't exist, create one + if (!form) { + form = document.createElement('form'); + + // insert the new form after the iframe + iframe.parentElement.insertBefore(form, iframe.nextSibling); + } + + // make sure we are actually posting to the right place + form.method = 'POST'; + form.action = postAction; + + // add the response token input to the form + form.appendChild(input); + + // away we go! + if (typeof submitCallback === "function") { + submitCallback.call(null, form); + } else { + form.submit(); + } + } + + return { + init: init, + _onReady: onReady, + _parseSigRequest: parseSigRequest, + _isDuoMessage: isDuoMessage, + _doPostBack: doPostBack + }; +})); diff --git a/geoportal/geomapfish_geoportal/static-ngeo/js/apps/duo/index.js b/geoportal/geomapfish_geoportal/static-ngeo/js/apps/duo/index.js new file mode 100644 index 000000000..9d9b95364 --- /dev/null +++ b/geoportal/geomapfish_geoportal/static-ngeo/js/apps/duo/index.js @@ -0,0 +1,9 @@ +import './Duo-Web-v2.js'; + +export default function initialize(sig_request) { + Duo.init({ + 'host': 'api-bfaf61bb.duosecurity.com', + 'sig_request': sig_request, + 'post_action': 'post_action' + }); +} diff --git a/geoportal/requirements.txt b/geoportal/requirements.txt index d95ed27df..28409dea9 100644 --- a/geoportal/requirements.txt +++ b/geoportal/requirements.txt @@ -1,3 +1,4 @@ #ptvsd # Remote debugging #wsgi-lineprof # Profiling #memory_profiler +duo-web==1.3.0 diff --git a/geoportal/vars.yaml b/geoportal/vars.yaml index 72b756d13..6d53b0f8c 100644 --- a/geoportal/vars.yaml +++ b/geoportal/vars.yaml @@ -27,6 +27,11 @@ vars: - fr - de + duo_web: + ikey: xxx + skey: yyy + akey: zzz + # All the application interfaces used to create the apache rewrite rules interfaces: - name: desktop