Skip to content

Commit

Permalink
Progress with LongLivd tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
blacelle committed Sep 19, 2024
1 parent b3f1baa commit e7b41fe
Show file tree
Hide file tree
Showing 14 changed files with 347 additions and 32 deletions.
23 changes: 23 additions & 0 deletions public/src/main/java/eu/solven/kumite/login/AccessTokenHolder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package eu.solven.kumite.login;

import java.util.UUID;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;

import lombok.Builder;
import lombok.Value;
import lombok.extern.jackson.Jacksonized;

@Value
@Builder
@Jacksonized
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class AccessTokenHolder {
String accessToken;
UUID playerId;
String tokenType;
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2
// in seconds
long expiresIn;
}
28 changes: 28 additions & 0 deletions server/README.MD
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Security

We rely on a SecurityWebFilterChain:

- A first one, authenticating browser calls, given OAuth2 providers like Github
- A second one, authenticating API calls, given a JWT.

These are defined in [SocialWebFluxSecurity](https://github.com/search?q=repo%3Asolven-eu%2Fkumite%20SocialWebFluxSecurity&type=code).

## OAuth2 SecurityWebFilterChain

Typically, a User would browse to `/html/login` and pick an OAuth2 provider of its choice. Once this flow done, a cookie is saved for Kumite domain authenticating the user based on an external OAuth2 identity.

A web-call can be done to :

- `/api/login/v1/user` to fetch information about the session-authenticated user. This would return a 401 if not authenticated.
- `/api/login/v1/token` to fetch a short-lived access_token/JWT enabling API queries. This would return a 302 to `/html/login` if not authenticated.

## JWT SecurityWebFilterChain

Typically, a Robot would API-call to `/api/v1/...` with an `Authentication: Bearer someJwt`.

Such a JWT can be fetched:

- Short-lived (1h) (e.g. as done by the `js` application):
- Long-lived (1y) (e.g. as done by the `js` application):

Long-lived JWT shall later be banned based on their `jid`.
8 changes: 8 additions & 0 deletions server/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@
<version>1.4.3</version>
<scope>test</scope>
</dependency>

<dependency>
<!-- Used to check links in HTML -->
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.18.1</version>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.function.Supplier;
Expand All @@ -25,6 +24,7 @@
import com.nimbusds.jwt.SignedJWT;

import eu.solven.kumite.account.KumiteUser;
import eu.solven.kumite.login.AccessTokenHolder;
import eu.solven.kumite.tools.IUuidGenerator;
import eu.solven.kumite.tools.JdkUuidGenerator;
import lombok.SneakyThrows;
Expand Down Expand Up @@ -122,7 +122,7 @@ public String generateAccessToken(KumiteUser user, Set<UUID> playerIds, Duration
* @return The generated JWT access token.
* @throws IllegalStateException
*/
public Map<String, ?> wrapInJwtToken(KumiteUser user, UUID playerId) {
public AccessTokenHolder wrapInJwtToken(KumiteUser user, UUID playerId) {
Duration accessTokenValidity = Duration.parse(env.getProperty(KEY_ACCESSTOKEN_EXP, "PT1H"));

if (accessTokenValidity.compareTo(Duration.parse("PT1H")) > 0) {
Expand All @@ -133,11 +133,13 @@ public String generateAccessToken(KumiteUser user, Set<UUID> playerIds, Duration
String accessToken = generateAccessToken(user, Set.of(playerId), accessTokenValidity);

// https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/
return Map.ofEntries(Map.entry("access_token", accessToken),
Map.entry("player_id", playerId),
Map.entry("token_type", "Bearer"),
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2
Map.entry("expires_in", accessTokenValidity.toSeconds()));
return AccessTokenHolder.builder()
.accessToken(accessToken)
.playerId(playerId)
.tokenType("Bearer")
.expiresIn(accessTokenValidity.toSeconds())
.build();

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import eu.solven.kumite.account.login.KumiteUsersRegistry;
import eu.solven.kumite.app.IKumiteSpringProfiles;
import eu.solven.kumite.app.webflux.LoginRouteButNotAuthenticatedException;
import eu.solven.kumite.login.AccessTokenHolder;
import eu.solven.kumite.player.IAccountPlayersRegistry;
import lombok.AllArgsConstructor;
import reactor.core.publisher.Mono;
Expand Down Expand Up @@ -71,7 +72,7 @@ public Mono<KumiteUser> user(@AuthenticationPrincipal Mono<OAuth2User> oauth2Use
} else {
return oauth2User.map(o -> {
String providerId = guessProviderId(o);
String sub = o.getAttribute("id").toString();
String sub = getSub(providerId, o);
KumiteUserRawRaw rawRaw = KumiteUserRawRaw.builder().providerId(providerId).sub(sub).build();
KumiteUser user = usersRegistry.getUser(rawRaw);

Expand All @@ -80,6 +81,18 @@ public Mono<KumiteUser> user(@AuthenticationPrincipal Mono<OAuth2User> oauth2Use
}
}

private String getSub(String providerId, OAuth2User o) {
if ("github".equals(providerId)) {
Object sub = o.getAttribute("id");
if (sub == null) {
throw new IllegalStateException("Invalid sub: " + sub);
}
return sub.toString();
} else {
throw new IllegalStateException("Not managed providerId: " + providerId);
}
}

private String guessProviderId(OAuth2User o) {
if ("testProviderId".equals(o.getAttribute("providerId"))) {
return "testProviderId";
Expand All @@ -88,7 +101,7 @@ private String guessProviderId(OAuth2User o) {
}

@GetMapping("/token")
public Mono<Map<String, ?>> token(@AuthenticationPrincipal Mono<OAuth2User> oauth2User,
public Mono<AccessTokenHolder> token(@AuthenticationPrincipal Mono<OAuth2User> oauth2User,
@RequestParam(name = "player_id", required = false) String rawPlayerId) {
return user(oauth2User).map(user -> {
UUID playerId = KumiteHandlerHelper.optUuid(Optional.ofNullable(rawPlayerId)).orElse(user.getPlayerId());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package eu.solven.kumite.app.webflux;

import java.util.UUID;

import org.springframework.http.MediaType;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;

import eu.solven.kumite.account.KumiteUser;
import eu.solven.kumite.account.login.KumiteTokenService;
import eu.solven.kumite.app.controllers.KumiteHandlerHelper;
import eu.solven.kumite.login.AccessTokenHolder;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Mono;

@AllArgsConstructor
@Slf4j
public class AccessTokenHandler {

final KumiteTokenService kumiteTokenService;

public Mono<ServerResponse> getAccessToken(ServerRequest request) {
KumiteUser user = null;
UUID queryPlayerId = KumiteHandlerHelper.uuid(request, "player_id");

return ReactiveSecurityContextHolder.getContext().map(securityContext -> {
log.info("2We need to check if playerId={} is valid given JWT={}",
queryPlayerId,
securityContext.getAuthentication());

return securityContext.getAuthentication();
}).flatMap(auth2 -> {
AccessTokenHolder tokenWrapper = kumiteTokenService.wrapInJwtToken(user, queryPlayerId);

return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(tokenWrapper));
});

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.reactive.function.server.RequestPredicate;
import org.springframework.web.reactive.function.server.RequestPredicates;
import org.springframework.web.reactive.function.server.RouterFunction;
Expand All @@ -29,7 +28,6 @@
import eu.solven.kumite.player.PlayersSearchHandler;
import eu.solven.kumite.webhook.WebhooksHandler;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Mono;

/**
* Redirect each route (e.g. `/games/someGameId`) to the appropriate handler.
Expand Down Expand Up @@ -122,17 +120,15 @@ public RouterFunction<ServerResponse> apiRoutes(GreetingHandler greetingHandler,
.filter((request, next) -> {
Optional<UUID> optPlayerId = KumiteHandlerHelper.optUuid(request, "player_id");

return Mono.justOrEmpty(optPlayerId).map(queryPlayerId -> {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
log.debug("1We need to check if playerId={} is valid given JWT={}", queryPlayerId, auth);

return ReactiveSecurityContextHolder.getContext().map(securityContext -> {
log.debug("2We need to check if playerId={} is valid given JWT={}",
return ReactiveSecurityContextHolder.getContext().map(securityContext -> {
Authentication authentication = securityContext.getAuthentication();
optPlayerId.ifPresent(queryPlayerId -> {
log.info("We need to check if playerId={} is valid given JWT={}",
queryPlayerId,
securityContext.getAuthentication());

return Mono.empty();
authentication);
});

return authentication;
}).then(next.handle(request));
}, ops -> {
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package eu.solven.kumite.app.webflux;

import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;

import java.util.Optional;
import java.util.UUID;

import org.springdoc.core.fn.builders.parameter.Builder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.reactive.function.server.RequestPredicate;
import org.springframework.web.reactive.function.server.RequestPredicates;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;

import eu.solven.kumite.app.controllers.KumiteHandlerHelper;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Mono;

/**
* Redirect each route (e.g. `/games/someGameId`) to the appropriate handler.
*
* @author Benoit Lacelle
*
*/
@Configuration(proxyBeanMethods = false)
@Slf4j
public class KumiteLoginRouter {

private static final RequestPredicate json(String path) {
final RequestPredicate json = RequestPredicates.accept(MediaType.APPLICATION_JSON);
return RequestPredicates.path("/api/v1" + path).and(json);
}

@Bean
public RouterFunction<ServerResponse> loginRoutes(AccessTokenHandler accessTokenHandler) {
Builder playerId = parameterBuilder().name("player_id").description("Search for a specific playerId");

return SpringdocRouteBuilder.route()

.GET(json("/token"),
accessTokenHandler::getAccessToken,
ops -> ops.operationId("getLongLivesAccessToken").parameter(playerId))

// Activate webhooks later. For now, we focus on long-polling
// .GET(json("/webhooks"),
// webhooksHandler::listWebhooks,
// ops -> ops.operationId("listWebhooks"))
// .PUT(RequestPredicates.PUT("/webhooks"),
// webhooksHandler::registerWebhook,
// ops -> ops.operationId("publishWebhook"))
// .DELETE(RequestPredicates.DELETE("/webhooks"),
// webhooksHandler::dropWebhooks,
// ops -> ops.operationId("deleteWebhook"))

.filter((request, next) -> {
Optional<UUID> optPlayerId = KumiteHandlerHelper.optUuid(request, "player_id");

return Mono.justOrEmpty(optPlayerId).map(queryPlayerId -> {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
log.debug("1We need to check if playerId={} is valid given JWT={}", queryPlayerId, auth);

return ReactiveSecurityContextHolder.getContext().map(securityContext -> {
log.debug("2We need to check if playerId={} is valid given JWT={}",
queryPlayerId,
securityContext.getAuthentication());

return Mono.empty();
});
}).then(next.handle(request));
}, ops -> {
})
.build();

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
public class KumiteSpaRouter {

@Value("classpath:/static/index.html")
private Resource indexHtml;
Resource indexHtml;

// https://github.com/springdoc/springdoc-openapi-demos/tree/2.x/springdoc-openapi-spring-boot-2-webflux-functional
// https://stackoverflow.com/questions/6845772/should-i-use-singular-or-plural-name-convention-for-rest-resources
Expand All @@ -41,10 +41,10 @@ public RouterFunction<ServerResponse> spaRoutes(Environment env) {
log.info("We should rely on PRD resources in `index.html`");
}

Resource filteredIndexHtml = filterIndexHTMl(env, indexHtml);
Resource filteredIndexHtml = filterIndexHtmlMl(env, indexHtml);

Mono<ServerResponse> responseIndexHtml =
ServerResponse.ok().contentType(MediaType.TEXT_HTML).bodyValue(indexHtml);
ServerResponse.ok().contentType(MediaType.TEXT_HTML).bodyValue(filteredIndexHtml);

return SpringdocRouteBuilder.route()

Expand All @@ -59,7 +59,7 @@ public RouterFunction<ServerResponse> spaRoutes(Environment env) {
.build();
}

private Resource filterIndexHTMl(Environment env, Resource indexHtmlResource) {
private Resource filterIndexHtmlMl(Environment env, Resource indexHtmlResource) {
if (env.acceptsProfiles(Profiles.of(IKumiteSpringProfiles.P_PRODMODE))) {
String indexHtml;
try {
Expand All @@ -86,23 +86,28 @@ private Resource filterIndexHTMl(Environment env, Resource indexHtmlResource) {
* @param indexHtml
* @return a minified version of index.html
*/
private String minifyHtml(String indexHtml) {
String minifyHtml(String indexHtml) {
String minified = indexHtml;

minified = minified.replace("/bootstrap.css", "/bootstrap.min.css");
minified = minified.replace("/bootstrap-icons.css", "/bootstrap-icons.min.css");

minified = minified.replace("/vue.esm-browser.js", "/vue.esm-browser.min.js");
minified = minified.replace("/vue-router.esm-browser.js", "/vue-router.esm-browser.min.js");
minified = minified.replace("/vue.esm-browser.js", "/vue.esm-browser.prod.js");
// https://github.com/vuejs/router/issues/694
// minified = minified.replace("/vue-router.esm-browser.js", "/vue-router.esm-browser.???.js");

minified = minified.replace("/bootstrap.esm.js", "/bootstrap.esm.min.js");

// https://unpkg.com/@vue/[email protected]/lib/esm/index.js
minified = minified.replace("/lib/esm/index.js", "/lib/esm/index.js");
// https://unpkg.com/@popperjs/[email protected]/dist/esm/index.js"
minified = minified.replace("/dist/esm/index.js", "/dist/esm/index.js");
minified = minified.replace("/pinia.esm-browser.js", "/pinia.esm-browser.min.js");

// No minified Pinia ESM?
// minified = minified.replace("/pinia.esm-browser.js", "/pinia.esm-browser.min.js");

minified = minified.replace("/vue-demi/lib/v3/index.mjs", "/vue-demi/lib/v3/index.min.mjs");
minified = minified.replace("/vue.esm-browser.js", "/vue.esm-browser.min.js");
minified = minified.replace("/vue.esm-browser.js", "/vue.esm-browser.prod.js");

return minified;
}
Expand Down
Loading

0 comments on commit e7b41fe

Please sign in to comment.