mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-07-06 09:58:51 +02:00
Merged in feature/logout_redirection (pull request #235)
Feature logout redirection
This commit is contained in:
@@ -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<URI> afterLogoutRedirectTo();
|
||||
}
|
||||
@@ -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<Props> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { authenticated, loading, error, t } = this.props;
|
||||
const { authenticated, redirecting, loading, error, t } = this.props;
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorPage
|
||||
@@ -41,7 +42,7 @@ class Logout extends React.Component<Props> {
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
} else if (loading || authenticated) {
|
||||
} else if (loading || authenticated || redirecting) {
|
||||
return <Loading />;
|
||||
} else {
|
||||
return <Redirect to="/login" />;
|
||||
@@ -52,11 +53,13 @@ class Logout extends React.Component<Props> {
|
||||
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
|
||||
};
|
||||
|
||||
@@ -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<Me> => {
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public class RedirectAfterLogoutDto {
|
||||
private String logoutRedirect;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user