diff --git a/docs/settings.rst b/docs/settings.rst index 7d30bb1..9a27e15 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -222,6 +222,31 @@ set to ``None`` to remove the badge. **Default**: :python:`'http://online.swagger.io/validator/'` |br| *Maps to parameter*: ``validatorUrl`` +PERSIST_AUTH +------------ + +Persist swagger-ui authorization data to local storage. |br| +**WARNING:** this may be a security risk as the data is stored unencrypted + +**Default**: :python:`'False` |br| +*Maps to parameter*: - + +REFETCH_SCHEMA_WITH_AUTH +------------------------ + +Re-fetch the OpenAPI document with the new credentials after authorization is performed through swagger-ui. + +**Default**: :python:`'False` |br| +*Maps to parameter*: - + +REFETCH_SCHEMA_ON_LOGOUT +------------------------ + +Re-fetch the OpenAPI document without credentials after authorization is removed through swagger-ui. + +**Default**: :python:`'False` |br| +*Maps to parameter*: - + OPERATIONS_SORTER ----------------- diff --git a/src/drf_yasg/app_settings.py b/src/drf_yasg/app_settings.py index 605e553..ac806fe 100644 --- a/src/drf_yasg/app_settings.py +++ b/src/drf_yasg/app_settings.py @@ -42,6 +42,9 @@ SWAGGER_DEFAULTS = { 'LOGOUT_URL': getattr(settings, 'LOGOUT_URL', None), 'SPEC_URL': None, 'VALIDATOR_URL': '', + 'PERSIST_AUTH': False, + 'REFETCH_SCHEMA_WITH_AUTH': False, + 'REFETCH_SCHEMA_ON_LOGOUT': False, 'OPERATIONS_SORTER': None, 'TAGS_SORTER': None, diff --git a/src/drf_yasg/renderers.py b/src/drf_yasg/renderers.py index 2932337..142a56a 100644 --- a/src/drf_yasg/renderers.py +++ b/src/drf_yasg/renderers.py @@ -134,6 +134,9 @@ class SwaggerUIRenderer(_UIRenderer): 'oauth2RedirectUrl': swagger_settings.OAUTH2_REDIRECT_URL, 'supportedSubmitMethods': swagger_settings.SUPPORTED_SUBMIT_METHODS, 'displayOperationId': swagger_settings.DISPLAY_OPERATION_ID, + 'persistAuth': swagger_settings.PERSIST_AUTH, + 'refetchWithAuth': swagger_settings.REFETCH_SCHEMA_WITH_AUTH, + 'refetchOnLogout': swagger_settings.REFETCH_SCHEMA_ON_LOGOUT, } data = filter_none(data) diff --git a/src/drf_yasg/static/drf-yasg/swagger-ui-init.js b/src/drf_yasg/static/drf-yasg/swagger-ui-init.js index 8db8642..f7dd8d9 100644 --- a/src/drf_yasg/static/drf-yasg/swagger-ui-init.js +++ b/src/drf_yasg/static/drf-yasg/swagger-ui-init.js @@ -4,11 +4,6 @@ var defaultSpecUrl = currentPath + '?format=openapi'; // load the saved authorization state from localStorage; ImmutableJS is used for consistency with swagger-ui state var savedAuth = Immutable.fromJS({}); -try { - savedAuth = Immutable.fromJS(JSON.parse(localStorage.getItem("drf-yasg-auth")) || {}); -} catch (e) { - localStorage.removeItem("drf-yasg-auth"); -} // global SwaggerUI config object; can be changed directly or by hooking initSwaggerUiConfig var swaggerUiConfig = { @@ -31,14 +26,7 @@ var swaggerUiConfig = { headers["X-CSRFToken"] = csrftoken.value; } - if (request.loadSpec) { - applyAuth(savedAuth, headers); - } return request; - }, - onComplete: function () { - preauthorizeAny(savedAuth, window.ui); - hookAuthActions(window.ui); } }; @@ -100,11 +88,85 @@ function initSwaggerUi() { * @param oauth2Settings OAUTH2_CONFIG from Django settings */ function initSwaggerUiConfig(swaggerSettings, oauth2Settings) { + var persistAuth = swaggerSettings.persistAuth; + var refetchWithAuth = swaggerSettings.refetchWithAuth; + var refetchOnLogout = swaggerSettings.refetchOnLogout; + delete swaggerSettings['persistAuth']; + delete swaggerSettings['refetchWithAuth']; + delete swaggerSettings['refetchOnLogout']; + for (var p in swaggerSettings) { if (swaggerSettings.hasOwnProperty(p)) { swaggerUiConfig[p] = swaggerSettings[p]; } } + + if (persistAuth || refetchWithAuth) { + var hookedAuth = false; + if (persistAuth) { + try { + savedAuth = Immutable.fromJS(JSON.parse(localStorage.getItem("drf-yasg-auth")) || {}); + } catch (e) { + localStorage.removeItem("drf-yasg-auth"); + } + } + + var oldOnComplete = swaggerUiConfig.onComplete; + swaggerUiConfig.onComplete = function () { + if (persistAuth) { + preauthorizeAny(savedAuth, window.ui); + } + + if (!hookedAuth) { + hookAuthActions(window.ui, persistAuth, refetchWithAuth, refetchOnLogout); + hookedAuth = true; + } + if (oldOnComplete) { + oldOnComplete(); + } + }; + + var specRequestsInFlight = []; + var oldRequestInterceptor = swaggerUiConfig.requestInterceptor; + swaggerUiConfig.requestInterceptor = function (request) { + var headers = request.headers || {}; + if (refetchWithAuth && request.loadSpec) { + var newUrl = applyAuth(savedAuth, request.url, headers) || request.url; + if (newUrl !== request.url) { + request.url = newUrl; + } + + // need to manually remember requests for spec urls because + // responseInterceptor has no reference to the request... + specRequestsInFlight.push(request.url); + } + + if (oldRequestInterceptor) { + request = oldRequestInterceptor(request); + } + return request; + }; + + var oldResponseInterceptor = swaggerUiConfig.responseInterceptor; + swaggerUiConfig.responseInterceptor = function (response) { + if (refetchWithAuth && specRequestsInFlight.indexOf(response.url) !== -1) { + // need setTimeout here because swagger-ui insists to updateUrl with the initial request url... + if (response.ok) { + setTimeout(function () { + window.ui.specActions.updateUrl(response.url); + }); + } + specRequestsInFlight = specRequestsInFlight.filter(function (val) { + return val !== response.url; + }); + } + + if (oldResponseInterceptor) { + response = oldResponseInterceptor(response); + } + return response; + } + } } /** @@ -128,54 +190,126 @@ function preauthorizeAny(savedAuth, sui) { } } +function _usp(url, fn) { + url = url.split('?'); + var usp = new URLSearchParams(url[1]); + fn(usp); + url[1] = usp.toString(); + return url.join('?'); +} + +function addQueryParam(url, key, value) { + return _usp(url, function (usp) { + usp.set(key, value); + }) +} + +function removeQueryParam(url, key) { + return _usp(url, function (usp) { + usp.delete(key); + }) +} + /** * Manually apply auth headers from the given auth object. - * @param savedAuth auth object saved from authActions.authorize - * @param requestHeaders target headers + * @param {object} authScheme auth object saved from authActions.authorize + * @param {string} requestUrl the request url + * @param {object} requestHeaders target headers + * @return string new request url */ -function applyAuth(savedAuth, requestHeaders) { - var schemeName = savedAuth.get("name"), schemeType = savedAuth.getIn(["schema", "type"]); +function applyAuth(authScheme, requestUrl, requestHeaders) { + requestHeaders = requestHeaders || {}; + var schemeName = authScheme.get("name"), schemeType = authScheme.getIn(["schema", "type"]); if (schemeType === "basic" && schemeName) { - var username = savedAuth.getIn(["value", "username"]); - var password = savedAuth.getIn(["value", "password"]); + var username = authScheme.getIn(["value", "username"]); + var password = authScheme.getIn(["value", "password"]); if (username && password) { requestHeaders["Authorization"] = "Basic " + btoa(username + ":" + password); } } else if (schemeType === "apiKey" && schemeName) { - var key = savedAuth.get("value"), _in = savedAuth.getIn(["schema", "in"]); - var paramName = savedAuth.getIn(["schema", "name"]); - if (key && paramName && _in === "header") { - requestHeaders[paramName] = key; - } - if (_in === "query") { - console.warn("WARNING: cannot apply apiKey query parameter via interceptor"); + var _in = authScheme.getIn(["schema", "in"]), paramName = authScheme.getIn(["schema", "name"]); + var key = authScheme.get("value"); + if (key && paramName) { + if (_in === "header") { + requestHeaders[paramName] = key; + } + if (_in === "query") { + if (requestUrl) { + requestUrl = addQueryParam(requestUrl, paramName, key); + } + else { + console.warn("WARNING: cannot apply apiKey query parameter via interceptor"); + } + } } } + + return requestUrl; +} + +/** + * Remove the given authorization scheme from the url. + * @param {object} authScheme + * @param {string} requestUrl + */ +function deauthUrl(authScheme, requestUrl) { + var schemeType = authScheme.getIn(["schema", "type"]); + if (schemeType === "apiKey") { + var _in = authScheme.getIn(["schema", "in"]), paramName = authScheme.getIn(["schema", "name"]); + if (_in === "query" && requestUrl && paramName) { + requestUrl = removeQueryParam(requestUrl, paramName); + } + } + return requestUrl; } /** * Hook the authorize and logout actions of SwaggerUI. * The hooks are used to persist authorization data and trigger schema refetch. * @param sui SwaggerUI or SwaggerUIBundle instance + * @param {boolean} persistAuth true to save auth to local storage + * @param {boolean} refetchWithAuth true to trigger schema fetch on login + * @param {boolean} refetchOnLogout true to trigger schema fetch on logout */ -function hookAuthActions(sui) { +function hookAuthActions(sui, persistAuth, refetchWithAuth, refetchOnLogout) { + if (!persistAuth && !refetchWithAuth) { + // nothing to do + return; + } + var originalAuthorize = sui.authActions.authorize; sui.authActions.authorize = function (authorization) { originalAuthorize(authorization); // authorization is map of scheme name to scheme object // need to use ImmutableJS because schema is already an ImmutableJS object var schemes = Immutable.fromJS(authorization); - var auth = schemes.valueSeq().first(); - localStorage.setItem("drf-yasg-auth", JSON.stringify(auth.toJSON())); - savedAuth = auth; - sui.specActions.download(); + savedAuth = schemes.valueSeq().first(); + + if (persistAuth) { + localStorage.setItem("drf-yasg-auth", JSON.stringify(savedAuth.toJSON())); + } + + if (refetchWithAuth) { + var url = sui.specSelectors.url(); + url = applyAuth(savedAuth, url) || url; + sui.specActions.download(url); + } }; var originalLogout = sui.authActions.logout; sui.authActions.logout = function (authorization) { if (savedAuth.get("name") === authorization[0]) { - localStorage.removeItem("drf-yasg-auth"); + var oldAuth = savedAuth.set("value", null); savedAuth = Immutable.fromJS({}); + if (persistAuth) { + localStorage.removeItem("drf-yasg-auth"); + } + + if (refetchWithAuth) { + var url = sui.specSelectors.url(); + url = deauthUrl(oldAuth, url) || url; + sui.specActions.download(url); + } } originalLogout(authorization); }; diff --git a/src/drf_yasg/static/drf-yasg/url-polyfill.min.js b/src/drf_yasg/static/drf-yasg/url-polyfill.min.js new file mode 100644 index 0000000..fc8a34f --- /dev/null +++ b/src/drf_yasg/static/drf-yasg/url-polyfill.min.js @@ -0,0 +1 @@ +(function(t){var e=function(){try{return!!Symbol.iterator}catch(e){return false}};var r=e();var n=function(t){var e={next:function(){var e=t.shift();return{done:e===void 0,value:e}}};if(r){e[Symbol.iterator]=function(){return e}}return e};var i=function(e){return encodeURIComponent(e).replace(/%20/g,"+")};var a=function(e){return decodeURIComponent(e).replace(/\+/g," ")};var o=function(){var a=function(e){Object.defineProperty(this,"_entries",{writable:true,value:{}});if(typeof e==="string"){if(e!==""){this._fromString(e)}}else if(e instanceof a){var r=this;e.forEach(function(e,t){r.append(t,e)})}else if(e!==null&&typeof e==="object"){if(Object.prototype.toString.call(e)==="[object Array]"){for(var t=0;tt[0]){return+1}else{return 0}});if(r._entries){r._entries={}}for(var e=0;e1?a(n[1]):"")}}})}})(typeof global!=="undefined"?global:typeof window!=="undefined"?window:typeof self!=="undefined"?self:this);(function(h){var e=function(){try{var e=new URL("b","http://a");e.pathname="c%20d";return e.href==="http://a/c%20d"&&e.searchParams}catch(e){return false}};var t=function(){var t=h.URL;var e=function(e,t){if(typeof e!=="string")e=String(e);var r=document,n;if(t&&(h.location===void 0||t!==h.location.href)){r=document.implementation.createHTMLDocument("");n=r.createElement("base");n.href=t;r.head.appendChild(n);try{if(n.href.indexOf(t)!==0)throw new Error(n.href)}catch(e){throw new Error("URL unable to set base "+t+" due to "+e)}}var i=r.createElement("a");i.href=e;if(n){r.body.appendChild(i);i.href=i.href}if(i.protocol===":"||!/:/.test(i.href)){throw new TypeError("Invalid URL")}Object.defineProperty(this,"_anchorElement",{value:i});var a=new URLSearchParams(this.search);var o=true;var s=true;var c=this;["append","delete","set"].forEach(function(e){var t=a[e];a[e]=function(){t.apply(a,arguments);if(o){s=false;c.search=a.toString();s=true}}});Object.defineProperty(this,"searchParams",{value:a,enumerable:true});var f=void 0;Object.defineProperty(this,"_updateSearchParams",{enumerable:false,configurable:false,writable:false,value:function(){if(this.search!==f){f=this.search;if(s){o=false;this.searchParams._fromString(this.search);o=true}}}})};var r=e.prototype;var n=function(t){Object.defineProperty(r,t,{get:function(){return this._anchorElement[t]},set:function(e){this._anchorElement[t]=e},enumerable:true})};["hash","host","hostname","port","protocol"].forEach(function(e){n(e)});Object.defineProperty(r,"search",{get:function(){return this._anchorElement["search"]},set:function(e){this._anchorElement["search"]=e;this._updateSearchParams()},enumerable:true});Object.defineProperties(r,{toString:{get:function(){var e=this;return function(){return e.href}}},href:{get:function(){return this._anchorElement.href.replace(/\?$/,"")},set:function(e){this._anchorElement.href=e;this._updateSearchParams()},enumerable:true},pathname:{get:function(){return this._anchorElement.pathname.replace(/(^\/?)/,"/")},set:function(e){this._anchorElement.pathname=e},enumerable:true},origin:{get:function(){var e={"http:":80,"https:":443,"ftp:":21}[this._anchorElement.protocol];var t=this._anchorElement.port!=e&&this._anchorElement.port!=="";return this._anchorElement.protocol+"//"+this._anchorElement.hostname+(t?":"+this._anchorElement.port:"")},enumerable:true},password:{get:function(){return""},set:function(e){},enumerable:true},username:{get:function(){return""},set:function(e){},enumerable:true}});e.createObjectURL=function(e){return t.createObjectURL.apply(t,arguments)};e.revokeObjectURL=function(e){return t.revokeObjectURL.apply(t,arguments)};h.URL=e};if(!e()){t()}if(h.location!==void 0&&!("origin"in h.location)){var r=function(){return h.location.protocol+"//"+h.location.hostname+(h.location.port?":"+h.location.port:"")};try{Object.defineProperty(h.location,"origin",{get:r,enumerable:true})}catch(e){setInterval(function(){h.location.origin=r()},100)}}})(typeof global!=="undefined"?global:typeof window!=="undefined"?window:typeof self!=="undefined"?self:this); \ No newline at end of file diff --git a/src/drf_yasg/templates/drf-yasg/swagger-ui.html b/src/drf_yasg/templates/drf-yasg/swagger-ui.html index a452a53..1a9b732 100644 --- a/src/drf_yasg/templates/drf-yasg/swagger-ui.html +++ b/src/drf_yasg/templates/drf-yasg/swagger-ui.html @@ -43,6 +43,7 @@ + {% endblock %} {% block extra_scripts %} diff --git a/testproj/testproj/settings/base.py b/testproj/testproj/settings/base.py index 3bb40e4..1880b72 100644 --- a/testproj/testproj/settings/base.py +++ b/testproj/testproj/settings/base.py @@ -95,6 +95,9 @@ REST_FRAMEWORK = { SWAGGER_SETTINGS = { 'LOGIN_URL': reverse_lazy('admin:login'), 'LOGOUT_URL': '/admin/logout', + 'PERSIST_AUTH': True, + 'REFETCH_SCHEMA_WITH_AUTH': True, + 'REFETCH_SCHEMA_ON_LOGOUT': True, 'DEFAULT_INFO': 'testproj.urls.swagger_info', @@ -106,6 +109,11 @@ SWAGGER_SETTINGS = { 'type': 'apiKey', 'name': 'Authorization', 'in': 'header' + }, + 'Query': { + 'type': 'apiKey', + 'name': 'auth', + 'in': 'query' } } }