Skip to content

Commit

Permalink
feat!(frontend): reload the page on login / logout success (#2432) (C…
Browse files Browse the repository at this point in the history
…P: 24.4) (#2481)

feat!(frontend): reload the page on login / logout success (#2432)

* refactor: prefer full page reload instead of AJAX request on logout

* Revert "refactor: prefer full page reload instead of AJAX request on logout"

This reverts commit 47880c1.

* fix: reload the page on login / logout success

* fix: restore Spring CSRF handling

* fix: normalize redirect as path

* test: prevent flackyness of parallel security tests

* fix(frontend): support context path in redirects

* chore: cleanup

* test(security): verify server-side logout redirect location

* test(security): make tests more reliable

* fix: normalize default redirect URL

* test(security): expect page reload from API

* chore: Java formatting

* fix: more accurate normalize url

* test(security): extra wait for page load

* test(security): extra wait for page load

* test(security): extra wait for page load

* test(security): more explicit load waiting

* chore: cleanup

* test(security): fix load waiting

* chore: Java formatting

* test: add implicit wait

* test: rollback asserting path, use builtin wait for main-view

* test: assertin path using script

* fix: reload with url mapping

* test: wait for invalid session reloads

* chore: remove test logger

* test: extra wait on open

* Revert "test: prevent flackyness of parallel security tests"

This reverts commit 77721ae.

* fix(frontend): prevent following requests when pageReloadNavigation is in use

* Revert "test: add implicit wait"

This reverts commit ed7d66d.

* feat!(frontend): onSuccess login / logout callbacks for custom after-flows

* test: use onSuccess callbacks

* test: wait for load when asserting current URL

* test: apply url mapping to logout path

* chore(frontend): remove unnecessary .toString()

* feat(frontend): support URL in login / logout options

---------

Co-authored-by: Vlad Rindevich <vladrin@vaadin.com>
Co-authored-by: Anton Platonov <platosha@gmail.com>
Co-authored-by: Soroosh Taefi <taefi.soroosh@gmail.com>
  • Loading branch information
4 people authored May 27, 2024
1 parent 598ee97 commit e604146
Show file tree
Hide file tree
Showing 9 changed files with 179 additions and 51 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ const client = new ConnectClient({
prefix: '../connect',
middlewares: [
new InvalidSessionMiddleware(async () => {
// @ts-ignore
window.reloadPending = true;
location.reload();
return {
error: true,
Expand Down
31 changes: 18 additions & 13 deletions packages/java/tests/spring/security/frontend/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,21 @@ export function isAuthenticated() {
* Uses `localStorage` for offline support.
*/
export async function login(username: string, password: string): Promise<LoginResult> {
const result = await loginImpl(username, password);
if (!result.error) {
// Get user info from endpoint
await appStore.fetchUserInfo();
authentication = {
timestamp: new Date().getTime(),
};
return await loginImpl(username, password, {
async onSuccess() {
// Get user info from endpoint
await appStore.fetchUserInfo();
authentication = {
timestamp: new Date().getTime(),
};

// Save the authentication to local storage
localStorage.setItem(AUTHENTICATION_KEY, JSON.stringify(authentication));
}
// Save the authentication to local storage
localStorage.setItem(AUTHENTICATION_KEY, JSON.stringify(authentication));

return result;
// @ts-ignore
window.reloadPending = true;
},
});
}

/**
Expand All @@ -69,6 +71,9 @@ export async function login(username: string, password: string): Promise<LoginRe
*/
export async function logout() {
setSessionExpired();
await logoutImpl();
appStore.clearUserInfo();
await logoutImpl({onSuccess() {
// @ts-ignore
window.reloadPending = true;
appStore.clearUserInfo();
}});
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ const client = new ConnectClient({
prefix: 'connect',
middlewares: [
new InvalidSessionMiddleware(async () => {
// @ts-ignore
window.reloadPending = true;
location.reload();
return {
error: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,27 @@ export class LoginView extends View implements AfterEnterObserver {
// If login was opened directly, use the default URL provided by the server.

// As we do not know if the target is a resource or a Fusion view or a Flow view, we cannot just use Router.go
window.location.href = result.redirectUrl || this.returnUrl || '';

// Navigation should happen automatically
// window.location.href = result.redirectUrl || this.returnUrl || '';
};

render() {
return html` <vaadin-login-overlay opened .error="${this.error}" @login="${this.login}"> </vaadin-login-overlay> `;
}

async login(event: CustomEvent): Promise<LoginResult> {
// @ts-ignore
window.reloadPending = true;
this.error = false;
const result = await login(event.detail.username, event.detail.password);
this.error = result.error;

if (!result.error) {
this.onSuccess(result);
} else {
// @ts-ignore
window.reloadPending = false;
}

return result;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ protected void configure(HttpSecurity http) throws Exception {
.permitAll();

super.configure(http);
setLoginView(http, "/login");
setLoginView(http, "/login", applyUrlMapping("/"));
http.logout().logoutUrl(applyUrlMapping("/logout"));

if (stateless) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ protected String getUrlMappingBasePath() {

protected void open(String path) {
getDriver().get(getRootURL() + getUrlMappingBasePath() + "/" + path);
waitForDocumentReady();
}

protected void openResource(String path) {
Expand Down Expand Up @@ -221,11 +222,13 @@ public void access_restricted_to_logged_in_users() {
openResource(path);
assertLoginViewShown();
loginUser();
assertResourceShown(path);
assertPageContains(contents);
logout();

openResource(path);
loginAdmin();
assertResourceShown(path);
assertPageContains(contents);
logout();

Expand All @@ -246,6 +249,7 @@ public void access_restricted_to_admin() {

openResource(path);
loginAdmin();
assertResourceShown(path);
String adminResult = getDriver().getPageSource();
Assert.assertTrue(adminResult.contains(contents));
logout();
Expand Down Expand Up @@ -307,8 +311,16 @@ protected void navigateTo(String path, boolean assertPathShown) {
}
}

protected void waitForDocumentReady() {
waitUntil(driver -> Boolean.TRUE
.equals(this.getCommandExecutor().executeScript(
"return !window.reloadPending && window.document.readyState "
+ "=== 'complete';")));
}

private TestBenchElement getMainView() {
return waitUntil(driver -> $("*").id("main-view"));
waitForDocumentReady();
return $("*").withId("main-view").waitForFirst();
}

protected void assertLoginViewShown() {
Expand All @@ -317,6 +329,7 @@ protected void assertLoginViewShown() {
}

private void assertRootPageShown() {
assertPathShown("");
waitUntil(drive -> $("h1").attribute("id", "header").exists());
String headerText = $("h1").id("header").getText();
Assert.assertEquals(ROOT_PAGE_HEADER_TEXT, headerText);
Expand All @@ -343,6 +356,7 @@ private void assertPathShown(String path) {
}

private void assertPathShown(String path, boolean includeUrlMapping) {
waitForDocumentReady();
waitUntil(driver -> {
String url = driver.getCurrentUrl();
String expected = getRootURL();
Expand Down Expand Up @@ -377,7 +391,9 @@ private void login(String username, String password) {
form.getUsernameField().setValue(username);
form.getPasswordField().setValue(password);
form.submit();
waitForDocumentReady();
waitUntilNot(driver -> $(LoginOverlayElement.class).exists());
waitForDocumentReady();
}

protected void refresh() {
Expand Down
95 changes: 88 additions & 7 deletions packages/ts/frontend/src/Authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,15 @@ async function updateCsrfTokensBasedOnResponse(response: Response): Promise<stri
return token;
}

async function doLogout(logoutUrl: string, headers: Record<string, string>) {
async function doLogout(logoutUrl: URL | string, headers: Record<string, string>) {
const response = await fetch(logoutUrl, { headers, method: 'POST' });
if (!response.ok) {
throw new Error(`failed to logout with response ${response.status}`);
}

await updateCsrfTokensBasedOnResponse(response);

return response;
}

export interface LoginResult {
Expand All @@ -59,12 +61,73 @@ export interface LoginResult {
defaultUrl?: string;
}

export type SuccessCallback = () => Promise<void> | void;

export type NavigateFunction = (path: string) => void;

export interface LoginOptions {
loginProcessingUrl?: string;
/**
* The URL for login request, defaults to `/login`.
*/
loginProcessingUrl?: URL | string;

/**
* The success callback.
*/
onSuccess?: SuccessCallback;

/**
* The navigation callback, called after successful login. The default
* reloads the page.
*/
navigate?: NavigateFunction;
}

export interface LogoutOptions {
logoutUrl?: string;
/**
* The URL for logout request, defaults to `/logout`.
*/
logoutUrl?: URL | string;

/**
* The success callback.
*/
onSuccess?: SuccessCallback;

/**
* The navigation callback, called after successful logout. The default
* reloads the page.
*/
navigate?: NavigateFunction;
}

function normalizePath(url: string): string {
// URL with context path
const effectiveBaseURL = new URL('.', document.baseURI);
const effectiveBaseURI = effectiveBaseURL.toString();

let normalized = url;

// Strip context path prefix
if (normalized.startsWith(effectiveBaseURL.pathname)) {
return `/${normalized.slice(effectiveBaseURL.pathname.length)}`;
}

// Strip base URI
normalized = normalized.startsWith(effectiveBaseURI) ? `/${normalized.slice(effectiveBaseURI.length)}` : normalized;

return normalized;
}

/**
* Navigates to the provided path using page reload.
*
* @param to - navigation target path
*/
function navigateWithPageReload(to: string) {
// Consider absolute path to be within application context
const url = to.startsWith('/') ? new URL(`.${to}`, document.baseURI) : to;
window.location.replace(url);
}

/**
Expand Down Expand Up @@ -109,6 +172,15 @@ export async function login(username: string, password: string, options?: LoginO
updateSpringCsrfMetaTags(springCsrfTokenInfo);
}

if (options?.onSuccess) {
await options.onSuccess();
}

const url = savedUrl ?? defaultUrl ?? document.baseURI;
const toPath = normalizePath(url);
const navigate = options?.navigate ?? navigateWithPageReload;
navigate(toPath);

return {
defaultUrl,
error: false,
Expand Down Expand Up @@ -141,23 +213,32 @@ export async function login(username: string, password: string, options?: LoginO
export async function logout(options?: LogoutOptions): Promise<void> {
// this assumes the default Spring Security logout configuration (handler URL)
const logoutUrl = options?.logoutUrl ?? 'logout';
let response: Response | undefined;
try {
const headers = getSpringCsrfTokenHeadersForAuthRequest(document);
await doLogout(logoutUrl, headers);
response = await doLogout(logoutUrl, headers);
} catch {
try {
const response = await fetch('?nocache');
const responseText = await response.text();
const noCacheResponse = await fetch('?nocache');
const responseText = await noCacheResponse.text();
const doc = new DOMParser().parseFromString(responseText, 'text/html');
const headers = getSpringCsrfTokenHeadersForAuthRequest(doc);
await doLogout(logoutUrl, headers);
response = await doLogout(logoutUrl, headers);
} catch (error) {
// clear the token if the call fails
clearSpringCsrfMetaTags();
throw error;
}
} finally {
CookieManager.remove(JWT_COOKIE_NAME);
if (response && response.ok && response.redirected) {
if (options?.onSuccess) {
await options.onSuccess();
}
const toPath = normalizePath(response.url);
const navigate = options?.navigate ?? navigateWithPageReload;
navigate(toPath);
}
}
}

Expand Down
Loading

0 comments on commit e604146

Please sign in to comment.