Skip to content

Commit 7ad7313

Browse files
committed
Implement logout in js
1 parent ecf6696 commit 7ad7313

File tree

12 files changed

+293
-18
lines changed

12 files changed

+293
-18
lines changed

js/e2e-tests/fake-player.mjs

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export default {
77

88
return response;
99
},
10-
10+
1111
async showLoginOptions(page) {
1212
await page.getByRole("link", { name: /You need to login/ }).click();
1313
},

js/e2e-tests/localhost-login-dev.spec.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ const url = "http://localhost:8080";
1111
//});
1212

1313
// We just check the login page is working OK
14-
test('showLoginOptions', async ({ page }) => {
14+
test("showLoginOptions", async ({ page }) => {
1515
await page.goto(url);
1616
await fakePlayer.showLoginOptions(page);
1717
});
1818

19-
test('login', async ({ page }) => {
19+
test("login", async ({ page }) => {
2020
await page.goto(url);
2121
await fakePlayer.login(page);
2222
});

js/src/main/resources/static/index.html

+6-1
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,12 @@
101101
component: KumiteBoard,
102102
props: true,
103103
},
104-
{ path: "/html/login", component: LoginChecker },
104+
{
105+
path: "/html/login",
106+
component: LoginChecker,
107+
// https://stackoverflow.com/questions/44783787/bind-query-to-props-with-vue-router
108+
// props: route => Object.assign({}, route.query, route.params)
109+
},
105110
{ path: "/html/about", component: AboutView },
106111
];
107112

Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
1+
import { ref, watch } from "vue";
2+
13
import { mapState } from "pinia";
24
import { useKumiteStore } from "./store.js";
35

6+
import { useRouter } from "vue-router";
7+
48
import LoginOptions from "./login-providers.js";
59

610
export default {
711
// https://vuejs.org/guide/components/registration#local-registration
812
components: {
913
LoginOptions,
1014
},
15+
props: {
16+
logout: {
17+
type: String,
18+
required: false,
19+
},
20+
},
1121
computed: {
1222
...mapState(useKumiteStore, ["nbAccountFetching", "account", "needsToLogin"]),
1323
...mapState(useKumiteStore, {
@@ -16,12 +26,82 @@ export default {
1626
},
1727
}),
1828
},
19-
setup() {
29+
setup(props) {
2030
const store = useKumiteStore();
31+
const router = useRouter();
2132

2233
store.loadUser();
2334

24-
return {};
35+
const csrfToken = ref({});
36+
const fetchCsrfToken = async function () {
37+
try {
38+
const response = await fetch(`/api/login/v1/csrf`);
39+
if (!response.ok) {
40+
throw new Error("Rejected request for logout");
41+
}
42+
43+
const json = await response.json();
44+
const csrfHeader = json.header;
45+
console.log("csrf header", csrfHeader);
46+
47+
const freshCrsfToken = response.headers.get(csrfHeader);
48+
if (!freshCrsfToken) {
49+
throw new Error("Invalid csrfToken");
50+
}
51+
console.debug("csrf", freshCrsfToken);
52+
53+
csrfToken.value = { header: csrfHeader, token: freshCrsfToken };
54+
} catch (e) {
55+
console.error("Issue on Network: ", e);
56+
}
57+
};
58+
59+
const doLogout = function () {
60+
console.info("Logout");
61+
async function fetchFromUrl(url) {
62+
// https://www.baeldung.com/spring-security-csrf
63+
// If we relied on Cookie, `.csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse())` we could get the csrfToken with:
64+
// const csrfToken = document.cookie.replace(/(?:(?:^|.*;\s*)XSRF-TOKEN\s*\=\s*([^;]*).*$)|^.*$/, '$1');
65+
66+
// https://stackoverflow.com/questions/60265617/how-do-you-include-a-csrf-token-in-a-vue-js-application-with-a-spring-boot-backe
67+
const headers = { [csrfToken.value.header]: csrfToken.value.token };
68+
69+
try {
70+
const response = await fetch(url, {
71+
method: "POST",
72+
headers: headers,
73+
74+
// https://stackoverflow.com/questions/39735496/redirect-after-a-fetch-post-call
75+
// https://github.com/whatwg/fetch/issues/601#issuecomment-502667208
76+
redirect: "follow",
77+
});
78+
if (!response.ok) {
79+
throw new Error("Rejected request for logout");
80+
}
81+
82+
const json = await response.json();
83+
84+
// We we can not intercept 3XX to extract the Location header, we introduced an API providing the Location as body of a 2XX.
85+
const logoutHtmlRoute = json["Location"];
86+
87+
console.info("Redirect to logout route", logoutHtmlRoute);
88+
89+
router.push(logoutHtmlRoute);
90+
91+
// We force reloading the page to take in account the removed SESSION
92+
// There should be a cleaner way to do it, without full-reload
93+
router.go(0);
94+
} catch (e) {
95+
console.error("Issue on Network: ", e);
96+
}
97+
}
98+
99+
fetchCsrfToken().then(() => {
100+
fetchFromUrl(`/logout`);
101+
});
102+
};
103+
104+
return { doLogout };
25105
},
26106
template: /* HTML */ `
27107
<div v-if="needsToLogin">
@@ -30,6 +110,10 @@ export default {
30110
<LoginOptions />
31111
</div>
32112
</div>
33-
<div v-else>Welcome {{user.raw.name}}. ?Logout?</div>
113+
<div v-else>
114+
Welcome {{user.raw.name}}.
115+
116+
<button class="btn btn-danger" @click="doLogout">Logout</button>
117+
</div>
34118
`,
35119
};

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

+3-3
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const useKumiteStore = defineStore("kumite", {
3535
nbBoardFetching: 0,
3636

3737
// Currently connected account
38-
account: { raw: {}},
38+
account: { raw: {} },
3939
tokens: {},
4040
// Initially, we assume we are logged-in as we may have a session cookie
4141
// May be turned to true by 401 on `loadUser()`
@@ -54,7 +54,7 @@ export const useKumiteStore = defineStore("kumite", {
5454
nbBoardOperating: 0,
5555
}),
5656
getters: {
57-
isLoggedIn: (store) => Object.keys(store.account.raw).length > 0,
57+
isLoggedIn: (store) => Object.keys(store.account.raw).length > 0,
5858
// There will be a way to choose a different playerId amongst the account playerIds
5959
playingPlayerId: (store) => store.account.playerId,
6060
// Default headers: we authenticate ourselves
@@ -212,7 +212,7 @@ export const useKumiteStore = defineStore("kumite", {
212212
}
213213
}
214214

215-
return this.ensureUser().then(user => {
215+
return this.ensureUser().then((user) => {
216216
console.log("We do have a User. Let's fetch tokens", user);
217217
return fetchFromUrl(`/api/login/v1/oauth2/token?player_id=${this.playingPlayerId}`);
218218
});

player/src/main/resources/application.yml

+2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
spring:
2+
main:
3+
banner-mode: "off"
24
application.name: kumite-player
35
# https://stackoverflow.com/questions/26105061/spring-boot-without-the-web-server
46
main.web-application-type: NONE

server/pom.xml

+7
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@
113113
<groupId>org.springframework.boot</groupId>
114114
<artifactId>spring-boot-starter-test</artifactId>
115115
<scope>test</scope>
116+
<exclusions>
117+
<exclusion>
118+
<!-- https://github.com/skyscreamer/JSONassert/pull/194 -->
119+
<groupId>com.vaadin.external.google</groupId>
120+
<artifactId>android-json</artifactId>
121+
</exclusion>
122+
</exclusions>
116123
</dependency>
117124
<dependency>
118125
<groupId>io.projectreactor</groupId>

server/src/main/java/eu/solven/kumite/account/login/SocialWebFluxSecurity.java

+34-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package eu.solven.kumite.account.login;
22

3+
import java.net.URI;
4+
35
import org.springframework.boot.autoconfigure.web.ServerProperties;
46
import org.springframework.context.annotation.Bean;
57
import org.springframework.context.annotation.Import;
@@ -15,6 +17,8 @@
1517
import org.springframework.security.oauth2.server.resource.web.server.BearerTokenServerAuthenticationEntryPoint;
1618
import org.springframework.security.web.server.SecurityWebFilterChain;
1719
import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler;
20+
import org.springframework.security.web.server.authentication.logout.RedirectServerLogoutSuccessHandler;
21+
import org.springframework.security.web.server.csrf.WebSessionServerCsrfTokenRepository;
1822
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
1923

2024
import com.nimbusds.jwt.JWT;
@@ -56,6 +60,10 @@ public SecurityWebFilterChain configureUi(ServerProperties serverProperties,
5660
// provider
5761
"/api/login/v1/**",
5862
"/oauth2/**",
63+
64+
// The logout route (do a POST to logout, i.e. clear the session)
65+
"/logout",
66+
5967
// Holds static resources (e.g. `/ui/js/store.js`)
6068
"/ui/js/**",
6169
"/ui/img/**",
@@ -69,6 +77,17 @@ public SecurityWebFilterChain configureUi(ServerProperties serverProperties,
6977
"/swagger-ui.html",
7078
"/swagger-ui/**",
7179
"/webjars/**"))
80+
81+
.csrf(csrf -> {
82+
csrf
83+
// https://docs.spring.io/spring-security/reference/reactive/exploits/csrf.html#webflux-csrf-configure-custom-repository
84+
// .csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse())
85+
86+
// This will NOT provide the CSRF as a header: `X-CSRF-TOKEN`
87+
// But it wlll help making it available on `/api/login/v1/csrf`
88+
.csrfTokenRepository(new WebSessionServerCsrfTokenRepository());
89+
})
90+
7291
.authorizeExchange(auth -> auth
7392

7493
// Login does not requires being loggged-in yet
@@ -78,9 +97,11 @@ public SecurityWebFilterChain configureUi(ServerProperties serverProperties,
7897
// Swagger UI
7998
.pathMatchers("/swagger-ui.html", "/swagger-ui/**")
8099
.permitAll()
81-
// The route used by the SPA
100+
101+
// The route used by the SPA: they all serve index.html
82102
.pathMatchers("/", "/html/**")
83103
.permitAll()
104+
84105
// Webjars and static resources
85106
.pathMatchers("/ui/js/**", "/ui/img/**", "/webjars/**", "/favicon.ico")
86107
.permitAll()
@@ -90,7 +111,9 @@ public SecurityWebFilterChain configureUi(ServerProperties serverProperties,
90111
.pathMatchers("/api/login/v1/user",
91112
"/api/login/v1/oauth2/token",
92113
"/api/login/v1/html",
93-
"/api/login/v1/providers")
114+
"/api/login/v1/providers",
115+
"/api/login/v1/csrf",
116+
"/api/login/v1/logout")
94117
.permitAll()
95118

96119
// The rest needs to be authenticated
@@ -99,7 +122,7 @@ public SecurityWebFilterChain configureUi(ServerProperties serverProperties,
99122

100123
// `/html/login` has to be synced with the SPA login route
101124
.formLogin(login -> {
102-
String loginPage = ("http%s://localhost:8080" + "/html/login").formatted(isSsl ? "s" : "");
125+
String loginPage = "/html/login".formatted(isSsl ? "s" : "");
103126
login.loginPage(loginPage)
104127
// Required not to get an NPE at `.build()`
105128
.authenticationManager(ram);
@@ -108,11 +131,17 @@ public SecurityWebFilterChain configureUi(ServerProperties serverProperties,
108131
// https://docs.spring.io/spring-security/reference/servlet/oauth2/client/authorization-grants.html
109132
// https://stackoverflow.com/questions/74242738/how-to-logout-from-oauth-signed-in-web-app-with-github
110133
.oauth2Login(oauth2 -> {
111-
String loginSuccess =
112-
("http%s://localhost:8080" + "/html/login?success").formatted(isSsl ? "s" : "");
134+
String loginSuccess = "/html/login?success".formatted(isSsl ? "s" : "");
113135
oauth2.authenticationSuccessHandler(new RedirectServerAuthenticationSuccessHandler(loginSuccess));
114136
})
115137

138+
.logout(logout -> {
139+
RedirectServerLogoutSuccessHandler logoutSuccessHandler = new RedirectServerLogoutSuccessHandler();
140+
// We need to redirect to a 2XX URL, and not a 3XX URL, as Fetch API can not intercept redirections.
141+
logoutSuccessHandler.setLogoutSuccessUrl(URI.create("/api/login/v1/logout"));
142+
logout.logoutSuccessHandler(logoutSuccessHandler);
143+
})
144+
116145
.build();
117146
}
118147

server/src/main/java/eu/solven/kumite/app/controllers/KumiteLoginController.java

+26
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@
1919
import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository;
2020
import org.springframework.security.oauth2.core.AuthorizationGrantType;
2121
import org.springframework.security.oauth2.core.user.OAuth2User;
22+
import org.springframework.security.web.server.csrf.CsrfToken;
2223
import org.springframework.stereotype.Controller;
2324
import org.springframework.web.bind.annotation.GetMapping;
2425
import org.springframework.web.bind.annotation.RequestMapping;
2526
import org.springframework.web.bind.annotation.RequestParam;
2627
import org.springframework.web.bind.annotation.RestController;
28+
import org.springframework.web.server.ServerWebExchange;
2729

2830
import eu.solven.kumite.account.KumiteUser;
2931
import eu.solven.kumite.account.KumiteUserRawRaw;
@@ -177,4 +179,28 @@ void checkValidPlayerId(KumiteUser user, UUID playerId) {
177179
}
178180
}
179181

182+
// It seems much easier to return the CSRF through a dedicated API, than through Cookies and Headers, as SpringBoot
183+
// seems to require advanced tweaking to get it populated automatically
184+
// https://docs.spring.io/spring-security/reference/reactive/exploits/csrf.html#webflux-csrf-configure-custom-repository
185+
@GetMapping("/csrf")
186+
public Mono<ResponseEntity<?>> csrf(ServerWebExchange exchange) {
187+
Mono<CsrfToken> csrfToken = exchange.getAttribute(CsrfToken.class.getName());
188+
if (csrfToken == null) {
189+
throw new IllegalStateException("No csrfToken is available");
190+
}
191+
192+
return csrfToken.map(csrf -> ResponseEntity.ok()
193+
.header(csrf.getHeaderName(), csrf.getToken())
194+
.body(Map.of("header", csrf.getHeaderName())));
195+
}
196+
197+
/**
198+
* @return the logout URL as a 2XX code, as 3XX can not be intercepted with Fetch API.
199+
* @see https://github.com/whatwg/fetch/issues/601#issuecomment-502667208
200+
*/
201+
@GetMapping("/logout")
202+
public Map<String, String> loginpage() {
203+
return Map.of(HttpHeaders.LOCATION, "/html/login?logout");
204+
}
205+
180206
}

server/src/main/resources/application.yml

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
spring:
2+
application.name: kumite-contests
3+
main:
4+
banner-mode: "off"
25
profiles:
36
# Do not add any default `include`, else `default` would never kicks in
47
group:
@@ -25,8 +28,6 @@ spring:
2528
- default_server
2629
unsafe_external_oauth2:
2730
- default_server
28-
main:
29-
banner-mode: "off"
3031

3132
logging:
3233
level:

0 commit comments

Comments
 (0)