diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/LogoutRedirection.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/LogoutRedirection.java new file mode 100644 index 0000000000..84fe2ddf7b --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/LogoutRedirection.java @@ -0,0 +1,12 @@ +package sonia.scm.api.v2.resources; + +import sonia.scm.plugin.ExtensionPoint; + +import java.net.URI; +import java.util.Optional; + +@ExtensionPoint(multi = false) +@FunctionalInterface +public interface LogoutRedirection { + Optional afterLogoutRedirectTo(); +} diff --git a/scm-ui/src/containers/Logout.js b/scm-ui/src/containers/Logout.js index fe6662da42..27c4902000 100644 --- a/scm-ui/src/containers/Logout.js +++ b/scm-ui/src/containers/Logout.js @@ -8,7 +8,7 @@ import { logout, isAuthenticated, isLogoutPending, - getLogoutFailure + getLogoutFailure, isRedirecting } from "../modules/auth"; import { Loading, ErrorPage } from "@scm-manager/ui-components"; import { getLogoutLink } from "../modules/indexResource"; @@ -16,6 +16,7 @@ import { getLogoutLink } from "../modules/indexResource"; type Props = { authenticated: boolean, loading: boolean, + redirecting: boolean, error: Error, logoutLink: string, @@ -32,7 +33,7 @@ class Logout extends React.Component { } render() { - const { authenticated, loading, error, t } = this.props; + const { authenticated, redirecting, loading, error, t } = this.props; if (error) { return ( { error={error} /> ); - } else if (loading || authenticated) { + } else if (loading || authenticated || redirecting) { return ; } else { return ; @@ -52,11 +53,13 @@ class Logout extends React.Component { const mapStateToProps = state => { const authenticated = isAuthenticated(state); const loading = isLogoutPending(state); + const redirecting = isRedirecting(state); const error = getLogoutFailure(state); const logoutLink = getLogoutLink(state); return { authenticated, loading, + redirecting, error, logoutLink }; diff --git a/scm-ui/src/modules/auth.js b/scm-ui/src/modules/auth.js index 4c0d22b23d..73ba013fa1 100644 --- a/scm-ui/src/modules/auth.js +++ b/scm-ui/src/modules/auth.js @@ -29,6 +29,7 @@ export const LOGOUT = "scm/auth/LOGOUT"; export const LOGOUT_PENDING = `${LOGOUT}_${types.PENDING_SUFFIX}`; export const LOGOUT_SUCCESS = `${LOGOUT}_${types.SUCCESS_SUFFIX}`; export const LOGOUT_FAILURE = `${LOGOUT}_${types.FAILURE_SUFFIX}`; +export const LOGOUT_REDIRECT = `${LOGOUT}_REDIRECT`; // Reducer @@ -54,6 +55,13 @@ export default function reducer( case LOGOUT_SUCCESS: return initialState; + case LOGOUT_REDIRECT: { + // we keep the current state until we are redirected to the new page + return { + ...state, + redirecting: true + }; + } default: return state; } @@ -89,10 +97,16 @@ export const logoutPending = () => { export const logoutSuccess = () => { return { - type: LOGOUT_SUCCESS + type: LOGOUT_SUCCESS, }; }; +export const redirectAfterLogout = () => { + return { + type: LOGOUT_REDIRECT + } +}; + export const logoutFailure = (error: Error) => { return { type: LOGOUT_FAILURE, @@ -130,11 +144,9 @@ export const fetchMeFailure = (error: Error) => { // side effects const callFetchMe = (link: string): Promise => { - return apiClient - .get(link) - .then(response => { - return response.json(); - }); + return apiClient.get(link).then(response => { + return response.json(); + }); }; export const login = ( @@ -192,11 +204,28 @@ export const logout = (link: string) => { dispatch(logoutPending()); return apiClient .delete(link) - .then(() => { - dispatch(logoutSuccess()); + .then(response => { + return response.status === 200 + ? response.json() + : new Promise(function(resolve) { + resolve(); + }); }) - .then(() => { - dispatch(fetchIndexResources()); + .then(json => { + let fetchIndex = true; + if (json && json.logoutRedirect) { + dispatch(redirectAfterLogout()); + window.location.assign(json.logoutRedirect); + fetchIndex = false; + } else { + dispatch(logoutSuccess()); + } + return fetchIndex; + }) + .then((fetchIndex: boolean) => { + if (fetchIndex) { + dispatch(fetchIndexResources()); + } }) .catch(error => { dispatch(logoutFailure(error)); @@ -244,3 +273,8 @@ export const isLogoutPending = (state: Object) => { export const getLogoutFailure = (state: Object) => { return getFailure(state, LOGOUT); }; + +export const isRedirecting = (state: Object) => { + return !!stateAuth(state).redirecting; +}; + diff --git a/scm-ui/src/modules/auth.test.js b/scm-ui/src/modules/auth.test.js index 1d7cb5096e..cdf8e7c661 100644 --- a/scm-ui/src/modules/auth.test.js +++ b/scm-ui/src/modules/auth.test.js @@ -26,7 +26,7 @@ import reducer, { FETCH_ME, LOGOUT, getLoginFailure, - getLogoutFailure + getLogoutFailure, isRedirecting, LOGOUT_REDIRECT, redirectAfterLogout, } from "./auth"; import configureMockStore from "redux-mock-store"; @@ -70,6 +70,17 @@ describe("auth reducer", () => { expect(state.authenticated).toBeUndefined(); }); + it("should keep state and set redirecting to true", () => { + const initialState = { + authenticated: true, + me + }; + const state = reducer(initialState, redirectAfterLogout()); + expect(state.me).toBe(initialState.me); + expect(state.authenticated).toBe(initialState.authenticated); + expect(state.redirecting).toBe(true); + }); + it("should set state authenticated and me after login", () => { const state = reducer(undefined, loginSuccess(me)); expect(state.me).toBe(me); @@ -224,6 +235,41 @@ describe("auth actions", () => { }); }); + it("should dispatch logout success and redirect", () => { + fetchMock.deleteOnce("/api/v2/auth/access_token", { + status: 200, + body: { logoutRedirect: "http://example.com/cas/logout" } + }); + + fetchMock.getOnce("/api/v2/me", { + status: 401 + }); + + fetchMock.getOnce("/api/v2/", { + _links: { + login: { + login: "/login" + } + } + }); + + window.location.assign = jest.fn(); + + const expectedActions = [ + { type: LOGOUT_PENDING }, + { type: LOGOUT_REDIRECT } + ]; + + const store = mockStore({}); + + return store.dispatch(logout("/auth/access_token")).then(() => { + expect(window.location.assign.mock.calls[0][0]).toBe( + "http://example.com/cas/logout" + ); + expect(store.getActions()).toEqual(expectedActions); + }); + }); + it("should dispatch logout failure", () => { fetchMock.deleteOnce("/api/v2/auth/access_token", { status: 500 @@ -307,4 +353,16 @@ describe("auth selectors", () => { it("should return unknown, if failure state is not set for LOGOUT", () => { expect(getLogoutFailure({})).toBeUndefined(); }); + + it("should return false, if redirecting is not set", () => { + expect(isRedirecting({})).toBe(false); + }); + + it("should return false, if redirecting is false", () => { + expect(isRedirecting({auth: { redirecting: false }})).toBe(false); + }); + + it("should return true, if redirecting is true", () => { + expect(isRedirecting({auth: { redirecting: true }})).toBe(true); + }); }); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AuthenticationResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AuthenticationResource.java index 47918080ab..deab53a708 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AuthenticationResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AuthenticationResource.java @@ -16,6 +16,8 @@ import javax.ws.rs.*; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; +import java.net.URI; +import java.util.Optional; @Path(AuthenticationResource.PATH) @AllowAnonymousAccess @@ -28,6 +30,9 @@ public class AuthenticationResource { private final AccessTokenBuilderFactory tokenBuilderFactory; private final AccessTokenCookieIssuer cookieIssuer; + @Inject(optional = true) + private LogoutRedirection logoutRedirection; + @Inject public AuthenticationResource(AccessTokenBuilderFactory tokenBuilderFactory, AccessTokenCookieIssuer cookieIssuer) { @@ -35,7 +40,6 @@ public class AuthenticationResource { this.cookieIssuer = cookieIssuer; } - @POST @Path("access_token") @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @@ -121,6 +125,7 @@ public class AuthenticationResource { @DELETE @Path("access_token") + @Produces(MediaType.APPLICATION_JSON) @StatusCodes({ @ResponseCode(code = 204, condition = "success"), @ResponseCode(code = 500, condition = "internal server error") @@ -135,7 +140,19 @@ public class AuthenticationResource { cookieIssuer.invalidate(request, response); // TODO anonymous access ?? - return Response.noContent().build(); + if (logoutRedirection == null) { + return Response.noContent().build(); + } else { + Optional uri = logoutRedirection.afterLogoutRedirectTo(); + if (uri.isPresent()) { + return Response.ok(new RedirectAfterLogoutDto(uri.get().toASCIIString())).build(); + } else { + return Response.noContent().build(); + } + } } + void setLogoutRedirection(LogoutRedirection logoutRedirection) { + this.logoutRedirection = logoutRedirection; + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RedirectAfterLogoutDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RedirectAfterLogoutDto.java new file mode 100644 index 0000000000..a6402a0190 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RedirectAfterLogoutDto.java @@ -0,0 +1,10 @@ +package sonia.scm.api.v2.resources; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class RedirectAfterLogoutDto { + private String logoutRedirect; +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java index 1123dc94ce..177f975971 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java @@ -23,10 +23,18 @@ import sonia.scm.security.DefaultAccessTokenCookieIssuer; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.ws.rs.core.MediaType; +import java.io.UnsupportedEncodingException; +import java.net.URI; import java.net.URISyntaxException; import java.util.Date; +import java.util.Optional; +import static java.net.URI.create; +import static java.util.Optional.empty; +import static java.util.Optional.of; +import static org.hamcrest.Matchers.containsString; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -49,6 +57,8 @@ public class AuthenticationResourceTest { private AccessTokenCookieIssuer cookieIssuer = new DefaultAccessTokenCookieIssuer(mock(ScmConfiguration.class)); + private MockHttpResponse response = new MockHttpResponse(); + private static final String AUTH_JSON_TRILLIAN = "{\n" + "\t\"cookie\": true,\n" + "\t\"grant_type\": \"password\",\n" + @@ -101,9 +111,11 @@ public class AuthenticationResourceTest { "}" ); + private AuthenticationResource authenticationResource; + @Before public void prepareEnvironment() { - AuthenticationResource authenticationResource = new AuthenticationResource(accessTokenBuilderFactory, cookieIssuer); + authenticationResource = new AuthenticationResource(accessTokenBuilderFactory, cookieIssuer); dispatcher.getRegistry().addSingletonResource(authenticationResource); AccessToken accessToken = mock(AccessToken.class); @@ -123,7 +135,6 @@ public class AuthenticationResourceTest { public void shouldAuthCorrectly() throws URISyntaxException { MockHttpRequest request = getMockHttpRequest(AUTH_JSON_TRILLIAN); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -134,7 +145,6 @@ public class AuthenticationResourceTest { public void shouldAuthCorrectlyWithFormencodedData() throws URISyntaxException { MockHttpRequest request = getMockHttpRequestUrlEncoded(AUTH_FORMENCODED_TRILLIAN); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -146,7 +156,6 @@ public class AuthenticationResourceTest { public void shouldNotAuthUserWithWrongPassword() throws URISyntaxException { MockHttpRequest request = getMockHttpRequest(AUTH_JSON_TRILLIAN_WRONG_PW); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -156,7 +165,6 @@ public class AuthenticationResourceTest { @Test public void shouldNotAuthNonexistingUser() throws URISyntaxException { MockHttpRequest request = getMockHttpRequest(AUTH_JSON_NOT_EXISTING_USER); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -187,16 +195,36 @@ public class AuthenticationResourceTest { @SubjectAware(username = "trillian", password = "secret") public void shouldSuccessfullyLogoutUser() throws URISyntaxException { MockHttpRequest request = MockHttpRequest.delete("/" + AuthenticationResource.PATH + "/access_token"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus()); } + @Test + public void shouldHandleLogoutRedirection() throws URISyntaxException, UnsupportedEncodingException { + authenticationResource.setLogoutRedirection(() -> of(create("http://example.com/cas/logout"))); + + MockHttpRequest request = MockHttpRequest.delete("/" + AuthenticationResource.PATH + "/access_token"); + + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + assertThat(response.getContentAsString(), containsString("http://example.com/cas/logout")); + } + + @Test + public void shouldHandleDisabledLogoutRedirection() throws URISyntaxException { + authenticationResource.setLogoutRedirection(Optional::empty); + + MockHttpRequest request = MockHttpRequest.delete("/" + AuthenticationResource.PATH + "/access_token"); + + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus()); + } private void shouldReturnBadRequest(String requestBody) throws URISyntaxException { MockHttpRequest request = getMockHttpRequest(requestBody); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -218,5 +246,4 @@ public class AuthenticationResourceTest { request.contentType(MediaType.APPLICATION_FORM_URLENCODED_TYPE); return request; } - }