Skip to content

Commit

Permalink
Merge pull request 'Fix #26403: Gracefully hide app when user logs ou…
Browse files Browse the repository at this point in the history
…t in another tab' (openanalytics#21) from feature/26403 into develop
  • Loading branch information
LEDfan committed Nov 4, 2021
2 parents 4ca9595 + d803efb commit c826cfd
Show file tree
Hide file tree
Showing 7 changed files with 145 additions and 35 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* ShinyProxy
*
* Copyright (C) 2016-2021 Open Analytics
*
* ===========================================================================
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Apache License as published by
* The Apache Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Apache License for more details.
*
* You should have received a copy of the Apache License
* along with this program. If not, see <http://www.apache.org/licenses/>
*/
package eu.openanalytics.shinyproxy;

import eu.openanalytics.shinyproxy.controllers.AppController;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.ExceptionTranslationFilter;
import org.springframework.security.web.util.ThrowableAnalyzer;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.OrRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.web.filter.GenericFilterBean;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
* A filter that blocks the default {@link AuthenticationEntryPoint} when requests are made to certain endpoints.
* These endpoints are:
* - /app_direct_i/* /* /** (without spaces), i.e. any subpath on the app_direct endpoint (thus not the page that loads the app)
* - /heartbeat/* , i.e. heartbeat requests
*
* When the filter detects that a user is not authenticated when requesting one of these endpoints, it returns the response:
* {"status":"error", "message":"shinyproxy_authentication_required"} with status code 401.
* This response is specific unique enough such that it can be handled by the frontend.
*
* See {@link AppController#appDirect} where a similar approach is used for apps that have been stopped.
*
* Note: this cannot be easily implemented as a {@link AuthenticationEntryPoint} since these entrypoints are sometimes,
* but not always overridden by the authentication backend.
*/
public class AuthenticationRequiredFilter extends GenericFilterBean {

private final ThrowableAnalyzer throwableAnalyzer = new DefaultThrowableAnalyzer();

private static final RequestMatcher REQUEST_MATCHER = new OrRequestMatcher(
new AntPathRequestMatcher("/app_direct_i/*/*/**"),
new AntPathRequestMatcher("/heartbeat/*"));

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;

try {
chain.doFilter(request, response);
} catch (IOException ex) {
throw ex;
} catch (Exception ex) {
if (REQUEST_MATCHER.matches(request) && isAuthException(ex)) {
if (response.isCommitted()) {
throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", ex);
}
SecurityContextHolder.getContext().setAuthentication(null);
response.setStatus(401);
response.getWriter().write("{\"status\":\"error\", \"message\":\"shinyproxy_authentication_required\"}");
return;
}
throw ex;
}
}

/**
* @param ex the exception to check
* @return whether this exception indicates that the user is not authenticated
*/
private boolean isAuthException(Exception ex) {
Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ex);
RuntimeException ase = (AuthenticationException) throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class, causeChain);
if (ase != null) {
return true;
}
ase = (AccessDeniedException) throwableAnalyzer.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
return ase != null;
}

/**
* Based on {@link ExceptionTranslationFilter.DefaultThrowableAnalyzer}
*/
private static final class DefaultThrowableAnalyzer extends ThrowableAnalyzer {
protected void initExtractorMap() {
super.initExtractorMap();

registerExtractor(ServletException.class, throwable -> {
ThrowableAnalyzer.verifyThrowableHierarchy(throwable,
ServletException.class);
return ((ServletException) throwable).getRootCause();
});
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import eu.openanalytics.containerproxy.service.UserService;
import eu.openanalytics.shinyproxy.controllers.HeartbeatController;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.access.ExceptionTranslationFilter;
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.stereotype.Component;
Expand All @@ -43,9 +44,6 @@ public class UISecurityConfig implements ICustomSecurityConfig {
@Inject
private OperatorService operatorService;

@Inject
private HeartbeatController heartbeatController;

@Override
public void apply(HttpSecurity http) throws Exception {
if (auth.hasAuthorization()) {
Expand All @@ -59,14 +57,14 @@ public void apply(HttpSecurity http) throws Exception {
// Limit access to the admin pages
http.authorizeRequests().antMatchers("/admin").hasAnyRole(userService.getAdminGroups());

// Add special handler for unAuthenticated users to the heartbeat endpoint
http.exceptionHandling().defaultAuthenticationEntryPointFor(heartbeatController, new AntPathRequestMatcher("/heartbeat/**", "POST"));
http.addFilterAfter(new AuthenticationRequiredFilter(), ExceptionTranslationFilter.class);
}

if (operatorService.isEnabled()) {
// running using operator
http.addFilterAfter(new OperatorCookieFilter(), AnonymousAuthenticationFilter.class);
http.authorizeRequests().antMatchers("/server-transfer").permitAll();
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,29 +21,23 @@
package eu.openanalytics.shinyproxy.controllers;

import eu.openanalytics.containerproxy.model.runtime.Proxy;
import eu.openanalytics.containerproxy.service.hearbeat.HeartbeatService;
import eu.openanalytics.containerproxy.service.ProxyService;
import eu.openanalytics.containerproxy.service.UserService;
import eu.openanalytics.containerproxy.service.hearbeat.HeartbeatService;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.inject.Inject;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;

@Controller
public class HeartbeatController implements AuthenticationEntryPoint {
public class HeartbeatController {

@Inject
private HeartbeatService heartbeatService;
Expand Down Expand Up @@ -82,14 +76,4 @@ public ResponseEntity<HashMap<String, String>> heartbeat(@PathVariable("proxyId"
}});
}

/**
* Special handler for the Heartbeat endpoint when the user is not authenticated.
* Instead of redirecting the user to the login form/URL we send a special message that can be used by the UI
* in order to properly handle a logout from a different tab (in case when an app keeps running even when the user logs out).
*/
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
httpServletResponse.setStatus(401);
httpServletResponse.getWriter().write("{\"status\":\"error\", \"message\":\"authentication_required\"}");
}
}
1 change: 0 additions & 1 deletion src/main/resources/static/css/default.css
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,6 @@ body > div#navbar { padding-top: 0px; }
}

.refreshButton {
width: 2000px;
font-size: 18px;
}

Expand Down
12 changes: 6 additions & 6 deletions src/main/resources/static/js/shiny.connections.js
Original file line number Diff line number Diff line change
Expand Up @@ -295,8 +295,8 @@ Shiny.connections = {
response.clone().json().then(function(clonedResponse) {
if (clonedResponse.status === "error" && clonedResponse.message === "app_stopped_or_non_existent") {
window.__shinyProxyParent.ui.showStoppedPage();
} else if (clonedResponse.status === "error" && clonedResponse.message === "authentication_required") {
window.__shinyProxyParent.ui.redirectToLogin();
} else if (clonedResponse.status === "error" && clonedResponse.message === "shinyproxy_authentication_required") {
window.__shinyProxyParent.ui.showLoggedOutPage();
}
});
}
Expand Down Expand Up @@ -331,9 +331,9 @@ Shiny.connections = {
if (res !== null && res.status === "error" && res.message === "app_stopped_or_non_existent") {
// app stopped
window.__shinyProxyParent.ui.showStoppedPage();
} else if (res !== null && res.status === "error" && res.message === "authentication_required") {
} else if (res !== null && res.status === "error" && res.message === "shinyproxy_authentication_required") {
// app stopped
window.__shinyProxyParent.ui.redirectToLogin();
window.__shinyProxyParent.ui.showLoggedOutPage();
}
}
});
Expand Down Expand Up @@ -451,8 +451,8 @@ Shiny.connections = {
if (res.message === "app_stopped_or_non_existent") {
cb(true);
return;
} else if (res.message === "authentication_required") {
Shiny.ui.redirectToLogin();
} else if (res.message === "shinyproxy_authentication_required") {
Shiny.ui.showLoggedOutPage();
// never call call-back, but just redirect to login page
return;
}
Expand Down
10 changes: 7 additions & 3 deletions src/main/resources/static/js/shiny.ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,14 @@ Shiny.ui = {
$('#appStopped').show();
},

redirectToLogin: function() {
showLoggedOutPage: function() {
if (!Shiny.app.runtimeState.navigatingAway) {
// only redirect to login when not navigating away, e.g. when logging out
window.location.href = Shiny.common.staticState.contextPath;
// only show it when not navigating away, e.g. when logging out in the current tab
$('#shinyframe').remove();
$("#reconnecting").hide();
$('#switchInstancesModal').modal('hide')
$("#navbar").hide();
$('#userLoggedOut').show();
}
},

Expand Down
12 changes: 10 additions & 2 deletions src/main/resources/templates/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,23 @@
<div class="loading-txt">
Failed to reload <span th:text="${appTitle}"></span><br><br>
<span class="refreshButton">
<button onClick="window.location.reload()">Refresh page</button>
<button onClick="window.location.reload()" class="btn btn-default">Refresh page</button>
</span>
</div>
</div>
<div id="appStopped" class="loading">
<div class="loading-txt">
This app has been stopped, you can now close this tab.<br><br>
<span class="refreshButton">
<button onClick="window.location.reload()">Restart app</button>
<button onClick="window.location.reload()" class="btn btn-default">Restart app</button>
</span>
</div>
</div>
<div id="userLoggedOut" class="loading">
<div class="loading-txt">
You logged out using another browser tab, you can now close this tab.<br><br>
<span class="refreshButton">
<a th:href="@{/}" class="btn btn-default">Login again</a>
</span>
</div>
</div>
Expand Down

0 comments on commit c826cfd

Please sign in to comment.