Skip to content

Commit

Permalink
Add Reactive One-Time Token Login support
Browse files Browse the repository at this point in the history
Closes gh-15699
  • Loading branch information
CrazyParanoid committed Sep 18, 2024
1 parent f396109 commit 8744faf
Show file tree
Hide file tree
Showing 20 changed files with 2,386 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2017 the original author or authors.
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -16,6 +16,9 @@

package org.springframework.security.config.web.server;

import org.springframework.security.web.server.authentication.ott.GenerateOneTimeTokenWebFilter;
import org.springframework.security.web.server.ui.OneTimeTokenSubmitPageGeneratingWebFilter;

/**
* @author Rob Winch
* @since 5.0
Expand Down Expand Up @@ -67,6 +70,16 @@ public enum SecurityWebFiltersOrder {

LOGOUT_PAGE_GENERATING,

/**
* {@link GenerateOneTimeTokenWebFilter}
*/
ONE_TIME_TOKEN,

/**
* {@link OneTimeTokenSubmitPageGeneratingWebFilter}
*/
ONE_TIME_TOKEN_SUBMIT_PAGE_GENERATING,

/**
* {@link org.springframework.security.web.server.context.SecurityContextServerWebExchangeWebFilter}
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@
import org.springframework.security.authentication.DelegatingReactiveAuthenticationManager;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver;
import org.springframework.security.authentication.ott.OneTimeToken;
import org.springframework.security.authentication.ott.reactive.InMemoryReactiveOneTimeTokenService;
import org.springframework.security.authentication.ott.reactive.OneTimeTokenReactiveAuthenticationManager;
import org.springframework.security.authentication.ott.reactive.ReactiveOneTimeTokenService;
import org.springframework.security.authorization.AuthenticatedReactiveAuthorizationManager;
import org.springframework.security.authorization.AuthorityReactiveAuthorizationManager;
import org.springframework.security.authorization.AuthorizationDecision;
Expand Down Expand Up @@ -151,6 +155,9 @@
import org.springframework.security.web.server.authentication.logout.SecurityContextServerLogoutHandler;
import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler;
import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;
import org.springframework.security.web.server.authentication.ott.GenerateOneTimeTokenWebFilter;
import org.springframework.security.web.server.authentication.ott.ServerGeneratedOneTimeTokenHandler;
import org.springframework.security.web.server.authentication.ott.ServerOneTimeTokenAuthenticationConverter;
import org.springframework.security.web.server.authorization.AuthorizationContext;
import org.springframework.security.web.server.authorization.AuthorizationWebFilter;
import org.springframework.security.web.server.authorization.DelegatingReactiveAuthorizationManager;
Expand Down Expand Up @@ -196,6 +203,7 @@
import org.springframework.security.web.server.ui.DefaultResourcesWebFilter;
import org.springframework.security.web.server.ui.LoginPageGeneratingWebFilter;
import org.springframework.security.web.server.ui.LogoutPageGeneratingWebFilter;
import org.springframework.security.web.server.ui.OneTimeTokenSubmitPageGeneratingWebFilter;
import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher;
Expand Down Expand Up @@ -347,6 +355,8 @@ public class ServerHttpSecurity {

private AnonymousSpec anonymous;

private OneTimeTokenLoginSpec oneTimeTokenLogin;

protected ServerHttpSecurity() {
}

Expand Down Expand Up @@ -1548,6 +1558,43 @@ public ServerHttpSecurity authenticationManager(ReactiveAuthenticationManager ma
return this;
}

/**
* Configures One-Time Token Login Support.
*
* <h2>Example Configuration</h2>
*
* <pre>
* &#064;Configuration
* &#064;EnableWebFluxSecurity
* public class SecurityConfig {
*
* &#064;Bean
* public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) throws Exception {
* http
* // ...
* .oneTimeTokenLogin(Customizer.withDefaults());
* return http.build();
* }
*
* &#064;Bean
* public ServerGeneratedOneTimeTokenHandler generatedOneTimeTokenHandler() {
* return new MyMagicLinkServerGeneratedOneTimeTokenHandler();
* }
*
* }
* </pre>
* @param oneTimeTokenLoginCustomizer the {@link Customizer} to provide more options
* for the {@link OneTimeTokenLoginSpec}
* @return the {@link ServerHttpSecurity} for further customizations
*/
public ServerHttpSecurity oneTimeTokenLogin(Customizer<OneTimeTokenLoginSpec> oneTimeTokenLoginCustomizer) {
if (this.oneTimeTokenLogin == null) {
this.oneTimeTokenLogin = new OneTimeTokenLoginSpec();
}
oneTimeTokenLoginCustomizer.customize(this.oneTimeTokenLogin);
return this;
}

/**
* Builds the {@link SecurityWebFilterChain}
* @return the {@link SecurityWebFilterChain}
Expand Down Expand Up @@ -1640,6 +1687,9 @@ else if (this.securityContextRepository != null) {
this.logout.configure(this);
}
this.requestCache.configure(this);
if (this.oneTimeTokenLogin != null) {
this.oneTimeTokenLogin.configure(this);
}
this.addFilterAt(new SecurityContextServerWebExchangeWebFilter(),
SecurityWebFiltersOrder.SECURITY_CONTEXT_SERVER_WEB_EXCHANGE);
if (this.authorizeExchange != null) {
Expand Down Expand Up @@ -5822,4 +5872,272 @@ private AnonymousSpec() {

}

/**
* Configures One-Time Token Login Support
*
* @author Max Batischev
* @since 6.4
* @see #oneTimeTokenLogin(Customizer)
*/
public final class OneTimeTokenLoginSpec {

private ReactiveAuthenticationManager authenticationManager;

private ReactiveOneTimeTokenService oneTimeTokenService = new InMemoryReactiveOneTimeTokenService();

private ServerAuthenticationConverter authenticationConverter = new ServerOneTimeTokenAuthenticationConverter();

private ServerAuthenticationFailureHandler authenticationFailureHandler;

private final RedirectServerAuthenticationSuccessHandler defaultSuccessHandler = new RedirectServerAuthenticationSuccessHandler(
"/");

private final List<ServerAuthenticationSuccessHandler> defaultSuccessHandlers = new ArrayList<>(
List.of(this.defaultSuccessHandler));

private final List<ServerAuthenticationSuccessHandler> authenticationSuccessHandlers = new ArrayList<>();

private ServerGeneratedOneTimeTokenHandler generatedOneTimeTokenHandler;

private String loginProcessingUrl = "/login/ott";

private String defaultSubmitPageUrl = "/login/ott";

private String generateTokenUrl = "/ott/generate";

private boolean submitPageEnabled = true;

protected void configure(ServerHttpSecurity http) {
configureSubmitPage(http);
configureOttGenerateFilter(http);
configureOttAuthenticationFilter(http);
configureDefaultLoginPage(http);
}

private void configureOttAuthenticationFilter(ServerHttpSecurity http) {
AuthenticationWebFilter ottWebFilter = new AuthenticationWebFilter(getAuthenticationManager());
ottWebFilter.setServerAuthenticationConverter(this.authenticationConverter);
ottWebFilter.setAuthenticationFailureHandler(getAuthenticationFailureHandler());
ottWebFilter.setAuthenticationSuccessHandler(getAuthenticationSuccessHandler());
ottWebFilter.setRequiresAuthenticationMatcher(
ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, this.loginProcessingUrl));
http.addFilterAt(ottWebFilter, SecurityWebFiltersOrder.AUTHENTICATION);
}

private void configureSubmitPage(ServerHttpSecurity http) {
if (!this.submitPageEnabled) {
return;
}
OneTimeTokenSubmitPageGeneratingWebFilter submitPage = new OneTimeTokenSubmitPageGeneratingWebFilter();
submitPage.setLoginProcessingUrl(this.loginProcessingUrl);

if (StringUtils.hasText(this.defaultSubmitPageUrl)) {
submitPage.setRequestMatcher(
ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, this.defaultSubmitPageUrl));
}
http.addFilterAt(submitPage, SecurityWebFiltersOrder.ONE_TIME_TOKEN_SUBMIT_PAGE_GENERATING);
}

private void configureOttGenerateFilter(ServerHttpSecurity http) {
GenerateOneTimeTokenWebFilter generateFilter = new GenerateOneTimeTokenWebFilter(getOneTimeTokenService(),
getGeneratedOneTimeTokenHandler());
generateFilter
.setRequestMatcher(ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, this.generateTokenUrl));
http.addFilterAt(generateFilter, SecurityWebFiltersOrder.ONE_TIME_TOKEN);
}

private void configureDefaultLoginPage(ServerHttpSecurity http) {
if (http.formLogin != null) {
for (WebFilter webFilter : http.webFilters) {
OrderedWebFilter orderedWebFilter = (OrderedWebFilter) webFilter;
if (orderedWebFilter.webFilter instanceof LoginPageGeneratingWebFilter loginPageGeneratingFilter) {
loginPageGeneratingFilter.setOneTimeTokenEnabled(true);
loginPageGeneratingFilter.setGenerateOneTimeTokenUrl(this.generateTokenUrl);
break;
}
}
}
}

/**
* Allows customizing the list of {@link ServerAuthenticationSuccessHandler}. The
* default list contains a {@link RedirectServerAuthenticationSuccessHandler} that
* redirects to "/".
* @param handlersConsumer the handlers consumer
* @return the {@link OneTimeTokenLoginSpec} to continue configuring
*/
public OneTimeTokenLoginSpec authenticationSuccessHandler(
Consumer<List<ServerAuthenticationSuccessHandler>> handlersConsumer) {
Assert.notNull(handlersConsumer, "handlersConsumer cannot be null");
handlersConsumer.accept(this.authenticationSuccessHandlers);
return this;
}

/**
* Specifies the {@link ServerAuthenticationSuccessHandler}
* @param authenticationSuccessHandler the
* {@link ServerAuthenticationSuccessHandler}.
*/
public OneTimeTokenLoginSpec authenticationSuccessHandler(
ServerAuthenticationSuccessHandler authenticationSuccessHandler) {
Assert.notNull(authenticationSuccessHandler, "authenticationSuccessHandler cannot be null");
authenticationSuccessHandler((handlers) -> {
handlers.clear();
handlers.add(authenticationSuccessHandler);
});
return this;
}

private ServerAuthenticationSuccessHandler getAuthenticationSuccessHandler() {
if (this.authenticationSuccessHandlers.isEmpty()) {
return new DelegatingServerAuthenticationSuccessHandler(this.defaultSuccessHandlers);
}
return new DelegatingServerAuthenticationSuccessHandler(this.authenticationSuccessHandlers);
}

/**
* Specifies the {@link ServerAuthenticationFailureHandler} to use when
* authentication fails. The default is redirecting to "/login?error" using
* {@link RedirectServerAuthenticationFailureHandler}
* @param authenticationFailureHandler the
* {@link ServerAuthenticationFailureHandler} to use when authentication fails.
*/
public OneTimeTokenLoginSpec authenticationFailureHandler(
ServerAuthenticationFailureHandler authenticationFailureHandler) {
Assert.notNull(authenticationFailureHandler, "authenticationFailureHandler cannot be null");
this.authenticationFailureHandler = authenticationFailureHandler;
return this;
}

ServerAuthenticationFailureHandler getAuthenticationFailureHandler() {
if (this.authenticationFailureHandler == null) {
this.authenticationFailureHandler = new RedirectServerAuthenticationFailureHandler("/login?error");
}
return this.authenticationFailureHandler;
}

/**
* Specifies {@link ReactiveAuthenticationManager} for one time tokens. Default
* implementation is {@link OneTimeTokenReactiveAuthenticationManager}
* @param authenticationManager
*/
public OneTimeTokenLoginSpec authenticationManager(ReactiveAuthenticationManager authenticationManager) {
Assert.notNull(authenticationManager, "authenticationManager cannot be null");
this.authenticationManager = authenticationManager;
return this;
}

ReactiveAuthenticationManager getAuthenticationManager() {
if (this.authenticationManager == null) {
ReactiveUserDetailsService userDetailsService = getBean(ReactiveUserDetailsService.class);
return new OneTimeTokenReactiveAuthenticationManager(getOneTimeTokenService(), userDetailsService);
}
return this.authenticationManager;
}

/**
* Configures the {@link ReactiveOneTimeTokenService} used to generate and consume
* {@link OneTimeToken}
* @param oneTimeTokenService
*/
public OneTimeTokenLoginSpec oneTimeTokenService(ReactiveOneTimeTokenService oneTimeTokenService) {
Assert.notNull(oneTimeTokenService, "oneTimeTokenService cannot be null");
this.oneTimeTokenService = oneTimeTokenService;
return this;
}

ReactiveOneTimeTokenService getOneTimeTokenService() {
ReactiveOneTimeTokenService oneTimeTokenService = getBeanOrNull(ReactiveOneTimeTokenService.class);
if (oneTimeTokenService != null) {
return oneTimeTokenService;
}
return this.oneTimeTokenService;
}

/**
* Use this {@link ServerAuthenticationConverter} when converting incoming
* requests to an {@link Authentication}. By default, the
* {@link ServerOneTimeTokenAuthenticationConverter} is used.
* @param authenticationConverter the {@link ServerAuthenticationConverter} to use
*/
public OneTimeTokenLoginSpec authenticationConverter(ServerAuthenticationConverter authenticationConverter) {
Assert.notNull(authenticationConverter, "authenticationConverter cannot be null");
this.authenticationConverter = authenticationConverter;
return this;
}

/**
* Specifies the URL to process the login request, defaults to {@code /login/ott}.
* Only POST requests are processed, for that reason make sure that you pass a
* valid CSRF token if CSRF protection is enabled.
* @param loginProcessingUrl
*/
public OneTimeTokenLoginSpec loginProcessingUrl(String loginProcessingUrl) {
Assert.hasText(loginProcessingUrl, "loginProcessingUrl cannot be null or empty");
this.loginProcessingUrl = loginProcessingUrl;
return this;
}

/**
* Configures whether the default one-time token submit page should be shown. This
* will prevent the {@link OneTimeTokenSubmitPageGeneratingWebFilter} to be
* configured.
* @param show
*/
public OneTimeTokenLoginSpec showDefaultSubmitPage(boolean show) {
this.submitPageEnabled = show;
return this;
}

/**
* Sets the URL that the default submit page will be generated. Defaults to
* {@code /login/ott}. If you don't want to generate the default submit page you
* should use {@link #showDefaultSubmitPage(boolean)}. Note that this method
* always invoke {@link #showDefaultSubmitPage(boolean)} passing {@code true}.
* @param submitPageUrl
*/
public OneTimeTokenLoginSpec defaultSubmitPageUrl(String submitPageUrl) {
Assert.hasText(submitPageUrl, "submitPageUrl cannot be null or empty");
this.defaultSubmitPageUrl = submitPageUrl;
showDefaultSubmitPage(true);
return this;
}

/**
* Specifies strategy to be used to handle generated one-time tokens.
* @param generatedOneTimeTokenHandler
*/
public OneTimeTokenLoginSpec generatedOneTimeTokenHandler(
ServerGeneratedOneTimeTokenHandler generatedOneTimeTokenHandler) {
Assert.notNull(generatedOneTimeTokenHandler, "generatedOneTimeTokenHandler cannot be null");
this.generatedOneTimeTokenHandler = generatedOneTimeTokenHandler;
return this;
}

/**
* Specifies the URL that a One-Time Token generate request will be processed.
* Defaults to {@code /ott/generate}.
* @param generateTokenUrl
*/
public OneTimeTokenLoginSpec generateTokenUrl(String generateTokenUrl) {
Assert.hasText(generateTokenUrl, "generateTokenUrl cannot be null or empty");
this.generateTokenUrl = generateTokenUrl;
return this;
}

private ServerGeneratedOneTimeTokenHandler getGeneratedOneTimeTokenHandler() {
if (this.generatedOneTimeTokenHandler == null) {
this.generatedOneTimeTokenHandler = getBeanOrNull(ServerGeneratedOneTimeTokenHandler.class);
}
if (this.generatedOneTimeTokenHandler == null) {
throw new IllegalStateException("""
A ServerGeneratedOneTimeTokenHandler is required to enable oneTimeTokenLogin().
Please provide it as a bean or pass it to the oneTimeTokenLogin() DSL.
""");
}
return this.generatedOneTimeTokenHandler;
}

}

}
Loading

0 comments on commit 8744faf

Please sign in to comment.