Skip to content

Commit c826cfd

Browse files
committed
Merge pull request 'Fix #26403: Gracefully hide app when user logs out in another tab' (#21) from feature/26403 into develop
2 parents 4ca9595 + d803efb commit c826cfd

7 files changed

Lines changed: 145 additions & 35 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* ShinyProxy
3+
*
4+
* Copyright (C) 2016-2021 Open Analytics
5+
*
6+
* ===========================================================================
7+
*
8+
* This program is free software: you can redistribute it and/or modify
9+
* it under the terms of the Apache License as published by
10+
* The Apache Software Foundation, either version 2 of the License, or
11+
* (at your option) any later version.
12+
*
13+
* This program is distributed in the hope that it will be useful,
14+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
* Apache License for more details.
17+
*
18+
* You should have received a copy of the Apache License
19+
* along with this program. If not, see <http://www.apache.org/licenses/>
20+
*/
21+
package eu.openanalytics.shinyproxy;
22+
23+
import eu.openanalytics.shinyproxy.controllers.AppController;
24+
import org.springframework.security.access.AccessDeniedException;
25+
import org.springframework.security.core.AuthenticationException;
26+
import org.springframework.security.core.context.SecurityContextHolder;
27+
import org.springframework.security.web.AuthenticationEntryPoint;
28+
import org.springframework.security.web.access.ExceptionTranslationFilter;
29+
import org.springframework.security.web.util.ThrowableAnalyzer;
30+
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
31+
import org.springframework.security.web.util.matcher.OrRequestMatcher;
32+
import org.springframework.security.web.util.matcher.RequestMatcher;
33+
import org.springframework.web.filter.GenericFilterBean;
34+
35+
import javax.servlet.FilterChain;
36+
import javax.servlet.ServletException;
37+
import javax.servlet.ServletRequest;
38+
import javax.servlet.ServletResponse;
39+
import javax.servlet.http.HttpServletRequest;
40+
import javax.servlet.http.HttpServletResponse;
41+
import java.io.IOException;
42+
43+
/**
44+
* A filter that blocks the default {@link AuthenticationEntryPoint} when requests are made to certain endpoints.
45+
* These endpoints are:
46+
* - /app_direct_i/* /* /** (without spaces), i.e. any subpath on the app_direct endpoint (thus not the page that loads the app)
47+
* - /heartbeat/* , i.e. heartbeat requests
48+
*
49+
* When the filter detects that a user is not authenticated when requesting one of these endpoints, it returns the response:
50+
* {"status":"error", "message":"shinyproxy_authentication_required"} with status code 401.
51+
* This response is specific unique enough such that it can be handled by the frontend.
52+
*
53+
* See {@link AppController#appDirect} where a similar approach is used for apps that have been stopped.
54+
*
55+
* Note: this cannot be easily implemented as a {@link AuthenticationEntryPoint} since these entrypoints are sometimes,
56+
* but not always overridden by the authentication backend.
57+
*/
58+
public class AuthenticationRequiredFilter extends GenericFilterBean {
59+
60+
private final ThrowableAnalyzer throwableAnalyzer = new DefaultThrowableAnalyzer();
61+
62+
private static final RequestMatcher REQUEST_MATCHER = new OrRequestMatcher(
63+
new AntPathRequestMatcher("/app_direct_i/*/*/**"),
64+
new AntPathRequestMatcher("/heartbeat/*"));
65+
66+
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
67+
HttpServletRequest request = (HttpServletRequest) req;
68+
HttpServletResponse response = (HttpServletResponse) res;
69+
70+
try {
71+
chain.doFilter(request, response);
72+
} catch (IOException ex) {
73+
throw ex;
74+
} catch (Exception ex) {
75+
if (REQUEST_MATCHER.matches(request) && isAuthException(ex)) {
76+
if (response.isCommitted()) {
77+
throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", ex);
78+
}
79+
SecurityContextHolder.getContext().setAuthentication(null);
80+
response.setStatus(401);
81+
response.getWriter().write("{\"status\":\"error\", \"message\":\"shinyproxy_authentication_required\"}");
82+
return;
83+
}
84+
throw ex;
85+
}
86+
}
87+
88+
/**
89+
* @param ex the exception to check
90+
* @return whether this exception indicates that the user is not authenticated
91+
*/
92+
private boolean isAuthException(Exception ex) {
93+
Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ex);
94+
RuntimeException ase = (AuthenticationException) throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class, causeChain);
95+
if (ase != null) {
96+
return true;
97+
}
98+
ase = (AccessDeniedException) throwableAnalyzer.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
99+
return ase != null;
100+
}
101+
102+
/**
103+
* Based on {@link ExceptionTranslationFilter.DefaultThrowableAnalyzer}
104+
*/
105+
private static final class DefaultThrowableAnalyzer extends ThrowableAnalyzer {
106+
protected void initExtractorMap() {
107+
super.initExtractorMap();
108+
109+
registerExtractor(ServletException.class, throwable -> {
110+
ThrowableAnalyzer.verifyThrowableHierarchy(throwable,
111+
ServletException.class);
112+
return ((ServletException) throwable).getRootCause();
113+
});
114+
}
115+
}
116+
117+
}

src/main/java/eu/openanalytics/shinyproxy/UISecurityConfig.java

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import eu.openanalytics.containerproxy.service.UserService;
2626
import eu.openanalytics.shinyproxy.controllers.HeartbeatController;
2727
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
28+
import org.springframework.security.web.access.ExceptionTranslationFilter;
2829
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
2930
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
3031
import org.springframework.stereotype.Component;
@@ -43,9 +44,6 @@ public class UISecurityConfig implements ICustomSecurityConfig {
4344
@Inject
4445
private OperatorService operatorService;
4546

46-
@Inject
47-
private HeartbeatController heartbeatController;
48-
4947
@Override
5048
public void apply(HttpSecurity http) throws Exception {
5149
if (auth.hasAuthorization()) {
@@ -59,14 +57,14 @@ public void apply(HttpSecurity http) throws Exception {
5957
// Limit access to the admin pages
6058
http.authorizeRequests().antMatchers("/admin").hasAnyRole(userService.getAdminGroups());
6159

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

6663
if (operatorService.isEnabled()) {
6764
// running using operator
6865
http.addFilterAfter(new OperatorCookieFilter(), AnonymousAuthenticationFilter.class);
6966
http.authorizeRequests().antMatchers("/server-transfer").permitAll();
7067
}
68+
7169
}
7270
}

src/main/java/eu/openanalytics/shinyproxy/controllers/HeartbeatController.java

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,29 +21,23 @@
2121
package eu.openanalytics.shinyproxy.controllers;
2222

2323
import eu.openanalytics.containerproxy.model.runtime.Proxy;
24-
import eu.openanalytics.containerproxy.service.hearbeat.HeartbeatService;
2524
import eu.openanalytics.containerproxy.service.ProxyService;
2625
import eu.openanalytics.containerproxy.service.UserService;
26+
import eu.openanalytics.containerproxy.service.hearbeat.HeartbeatService;
2727
import org.springframework.http.MediaType;
2828
import org.springframework.http.ResponseEntity;
2929
import org.springframework.security.access.AccessDeniedException;
30-
import org.springframework.security.core.AuthenticationException;
31-
import org.springframework.security.web.AuthenticationEntryPoint;
3230
import org.springframework.stereotype.Controller;
3331
import org.springframework.web.bind.annotation.PathVariable;
3432
import org.springframework.web.bind.annotation.RequestMapping;
3533
import org.springframework.web.bind.annotation.RequestMethod;
3634
import org.springframework.web.bind.annotation.ResponseBody;
3735

3836
import javax.inject.Inject;
39-
import javax.servlet.ServletException;
40-
import javax.servlet.http.HttpServletRequest;
41-
import javax.servlet.http.HttpServletResponse;
42-
import java.io.IOException;
4337
import java.util.HashMap;
4438

4539
@Controller
46-
public class HeartbeatController implements AuthenticationEntryPoint {
40+
public class HeartbeatController {
4741

4842
@Inject
4943
private HeartbeatService heartbeatService;
@@ -82,14 +76,4 @@ public ResponseEntity<HashMap<String, String>> heartbeat(@PathVariable("proxyId"
8276
}});
8377
}
8478

85-
/**
86-
* Special handler for the Heartbeat endpoint when the user is not authenticated.
87-
* Instead of redirecting the user to the login form/URL we send a special message that can be used by the UI
88-
* in order to properly handle a logout from a different tab (in case when an app keeps running even when the user logs out).
89-
*/
90-
@Override
91-
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
92-
httpServletResponse.setStatus(401);
93-
httpServletResponse.getWriter().write("{\"status\":\"error\", \"message\":\"authentication_required\"}");
94-
}
9579
}

src/main/resources/static/css/default.css

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,6 @@ body > div#navbar { padding-top: 0px; }
120120
}
121121

122122
.refreshButton {
123-
width: 2000px;
124123
font-size: 18px;
125124
}
126125

src/main/resources/static/js/shiny.connections.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -295,8 +295,8 @@ Shiny.connections = {
295295
response.clone().json().then(function(clonedResponse) {
296296
if (clonedResponse.status === "error" && clonedResponse.message === "app_stopped_or_non_existent") {
297297
window.__shinyProxyParent.ui.showStoppedPage();
298-
} else if (clonedResponse.status === "error" && clonedResponse.message === "authentication_required") {
299-
window.__shinyProxyParent.ui.redirectToLogin();
298+
} else if (clonedResponse.status === "error" && clonedResponse.message === "shinyproxy_authentication_required") {
299+
window.__shinyProxyParent.ui.showLoggedOutPage();
300300
}
301301
});
302302
}
@@ -331,9 +331,9 @@ Shiny.connections = {
331331
if (res !== null && res.status === "error" && res.message === "app_stopped_or_non_existent") {
332332
// app stopped
333333
window.__shinyProxyParent.ui.showStoppedPage();
334-
} else if (res !== null && res.status === "error" && res.message === "authentication_required") {
334+
} else if (res !== null && res.status === "error" && res.message === "shinyproxy_authentication_required") {
335335
// app stopped
336-
window.__shinyProxyParent.ui.redirectToLogin();
336+
window.__shinyProxyParent.ui.showLoggedOutPage();
337337
}
338338
}
339339
});
@@ -451,8 +451,8 @@ Shiny.connections = {
451451
if (res.message === "app_stopped_or_non_existent") {
452452
cb(true);
453453
return;
454-
} else if (res.message === "authentication_required") {
455-
Shiny.ui.redirectToLogin();
454+
} else if (res.message === "shinyproxy_authentication_required") {
455+
Shiny.ui.showLoggedOutPage();
456456
// never call call-back, but just redirect to login page
457457
return;
458458
}

src/main/resources/static/js/shiny.ui.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,14 @@ Shiny.ui = {
112112
$('#appStopped').show();
113113
},
114114

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

src/main/resources/templates/app.html

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,23 @@
6565
<div class="loading-txt">
6666
Failed to reload <span th:text="${appTitle}"></span><br><br>
6767
<span class="refreshButton">
68-
<button onClick="window.location.reload()">Refresh page</button>
68+
<button onClick="window.location.reload()" class="btn btn-default">Refresh page</button>
6969
</span>
7070
</div>
7171
</div>
7272
<div id="appStopped" class="loading">
7373
<div class="loading-txt">
7474
This app has been stopped, you can now close this tab.<br><br>
7575
<span class="refreshButton">
76-
<button onClick="window.location.reload()">Restart app</button>
76+
<button onClick="window.location.reload()" class="btn btn-default">Restart app</button>
77+
</span>
78+
</div>
79+
</div>
80+
<div id="userLoggedOut" class="loading">
81+
<div class="loading-txt">
82+
You logged out using another browser tab, you can now close this tab.<br><br>
83+
<span class="refreshButton">
84+
<a th:href="@{/}" class="btn btn-default">Login again</a>
7785
</span>
7886
</div>
7987
</div>

0 commit comments

Comments
 (0)