diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/.gitignore b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/.gitignore
new file mode 100644
index 000000000..c2065bc26
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/.gitignore
@@ -0,0 +1,37 @@
+HELP.md
+.gradle
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+out/
+!**/src/main/**/out/
+!**/src/test/**/out/
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+
+### VS Code ###
+.vscode/
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/README.md b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/README.md
new file mode 100644
index 000000000..fdf74d537
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/README.md
@@ -0,0 +1,3 @@
+This application uses Spring Boot Docker Compose to start a [Maildev](https://github.com/maildev/maildev) container.
+
+After requesting a token on `http://localhost:8080/login`, access `http://localhost:1080` to verify the email containing the magic link.
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/build.gradle b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/build.gradle
new file mode 100644
index 000000000..9a8a64fbd
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/build.gradle
@@ -0,0 +1,35 @@
+plugins {
+ id 'java'
+ alias(libs.plugins.io.spring.dependency.management)
+ alias(libs.plugins.org.springframework.boot)
+}
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(17)
+ }
+}
+
+repositories {
+ mavenLocal()
+ mavenCentral()
+ maven { url "https://repo.spring.io/milestone" }
+ maven { url "https://repo.spring.io/snapshot" }
+}
+
+dependencies {
+ implementation 'org.springframework.boot:spring-boot-starter-security'
+ implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-mail'
+ implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'org.springframework.security:spring-security-test'
+ testImplementation 'com.icegreen:greenmail-junit5:2.0.1'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+ runtimeOnly 'org.springframework.boot:spring-boot-docker-compose'
+}
+
+tasks.named('test') {
+ useJUnitPlatform()
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/compose.yml b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/compose.yml
new file mode 100644
index 000000000..85a825b5b
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/compose.yml
@@ -0,0 +1,6 @@
+services:
+ maildev:
+ image: maildev/maildev:2.1.0
+ ports:
+ - "1080:1080"
+ - "1025:1025"
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/gradle.properties b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/gradle.properties
new file mode 100644
index 000000000..a5a6444df
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/gradle.properties
@@ -0,0 +1,4 @@
+version=6.1.1
+spring-security.version=7.0.0-SNAPSHOT
+org.gradle.jvmargs=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError
+org.gradle.caching=true
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/gradle/libs.versions.toml b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/gradle/libs.versions.toml
new file mode 120000
index 000000000..ebb52ed22
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/gradle/libs.versions.toml
@@ -0,0 +1 @@
+../../../../../../../gradle/libs.versions.toml
\ No newline at end of file
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/gradle/wrapper/gradle-wrapper.jar b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 000000000..e6441136f
Binary files /dev/null and b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/gradle/wrapper/gradle-wrapper.properties b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000..a4413138c
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/gradlew b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/gradlew
new file mode 100755
index 000000000..b740cf133
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/gradlew
@@ -0,0 +1,249 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/gradlew.bat b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/gradlew.bat
new file mode 100644
index 000000000..25da30dbd
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/gradlew.bat
@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/settings.gradle b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/settings.gradle
new file mode 100644
index 000000000..733fda690
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/settings.gradle
@@ -0,0 +1,8 @@
+pluginManagement {
+ repositories {
+ mavenCentral()
+ gradlePluginPortal()
+ maven { url 'https://repo.spring.io/milestone' }
+ maven { url "https://repo.spring.io/snapshot" }
+ }
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/java/org/example/magiclink/CustomPagesSecurityConfig.java b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/java/org/example/magiclink/CustomPagesSecurityConfig.java
new file mode 100644
index 000000000..79d2726d7
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/java/org/example/magiclink/CustomPagesSecurityConfig.java
@@ -0,0 +1,46 @@
+package org.example.magiclink;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+
+@Profile("custom-pages")
+@Configuration(proxyBeanMethods = false)
+public class CustomPagesSecurityConfig {
+
+ @Controller
+ @Profile("custom-pages")
+ static class LoginController {
+ @GetMapping("/login/form")
+ public String login() {
+ return "login";
+ }
+
+ @GetMapping("/login/ott")
+ public String ott() {
+ return "ott";
+ }
+ }
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ // @formatter:off
+ http
+ .authorizeHttpRequests((authz) -> authz.anyRequest().authenticated())
+ .formLogin((form) -> form
+ .loginPage("/login/form").permitAll()
+ .factor(Customizer.withDefaults())
+ )
+ .oneTimeTokenLogin((ott) -> ott
+ .loginPage("/login/ott").permitAll()
+ .factor(Customizer.withDefaults())
+ );
+ // @formatter:on
+ return http.build();
+ }
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/java/org/example/magiclink/DefaultSecurityConfig.java b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/java/org/example/magiclink/DefaultSecurityConfig.java
new file mode 100644
index 000000000..533559cb6
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/java/org/example/magiclink/DefaultSecurityConfig.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright 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.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.example.magiclink;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.web.SecurityFilterChain;
+
+@Profile("default")
+@Configuration(proxyBeanMethods = false)
+class DefaultSecurityConfig {
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ // @formatter:off
+ http
+ .authorizeHttpRequests((authz) -> authz.anyRequest().authenticated())
+ .formLogin((form) -> form.factor(Customizer.withDefaults()))
+ .oneTimeTokenLogin((ott) -> ott.factor(Customizer.withDefaults()));
+ // @formatter:on
+ return http.build();
+ }
+
+
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/java/org/example/magiclink/ElevatedSecurityPageSecurityConfig.java b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/java/org/example/magiclink/ElevatedSecurityPageSecurityConfig.java
new file mode 100644
index 000000000..4cf69f4ba
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/java/org/example/magiclink/ElevatedSecurityPageSecurityConfig.java
@@ -0,0 +1,63 @@
+package org.example.magiclink;
+
+import java.time.Duration;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+import org.springframework.http.HttpMethod;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
+import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configurers.MfaConfigurer;
+import org.springframework.security.web.DefaultSecurityFilterChain;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.AuthenticationFilter;
+import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
+import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+
+import static org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher.pathPattern;
+
+@Profile("elevated-security")
+@Configuration(proxyBeanMethods = false)
+public class ElevatedSecurityPageSecurityConfig {
+
+ @Controller
+ @Profile("elevated-security")
+ static class LoginController {
+ @GetMapping("/login/form")
+ public String login() {
+ return "login";
+ }
+
+ @GetMapping("/login/ott")
+ public String ott() {
+ return "ott";
+ }
+ }
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ // @formatter:off
+ http
+ .authorizeHttpRequests((authz) -> authz
+ .requestMatchers("/profile").hasAuthority("profile:read")
+ .anyRequest().authenticated()
+ )
+ .formLogin((form) -> form
+ .loginPage("/login/form").permitAll()
+ .factor((f) -> f.grants(Duration.ofMinutes(1), "profile:read"))
+ )
+ .oneTimeTokenLogin((ott) -> ott
+ .loginPage("/login/ott").permitAll()
+ .factor(Customizer.withDefaults())
+ );
+
+ // @formatter:on
+ return http.build();
+ }
+
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/java/org/example/magiclink/MagicLinkApplication.java b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/java/org/example/magiclink/MagicLinkApplication.java
new file mode 100644
index 000000000..3664b1244
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/java/org/example/magiclink/MagicLinkApplication.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 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.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.example.magiclink;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.provisioning.InMemoryUserDetailsManager;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+
+@SpringBootApplication
+public class MagicLinkApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(MagicLinkApplication.class, args);
+ }
+
+ @Controller
+ static class AppController {
+ @GetMapping("/profile")
+ String profile() {
+ return "profile";
+ }
+ }
+
+ @Bean
+ InMemoryUserDetailsManager userDetailsService() {
+ UserDetails user = User.withDefaultPasswordEncoder()
+ .username("user")
+ .password("password")
+ .roles("USER")
+ .build();
+ return new InMemoryUserDetailsManager(user);
+ }
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/java/org/example/magiclink/MagicLinkOneTimeTokenGenerationSuccessHandler.java b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/java/org/example/magiclink/MagicLinkOneTimeTokenGenerationSuccessHandler.java
new file mode 100644
index 000000000..24fd7dfe3
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/java/org/example/magiclink/MagicLinkOneTimeTokenGenerationSuccessHandler.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 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.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.example.magiclink;
+
+import java.io.IOException;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.springframework.mail.SimpleMailMessage;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.security.authentication.ott.OneTimeToken;
+import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
+import org.springframework.stereotype.Component;
+
+@Component
+public class MagicLinkOneTimeTokenGenerationSuccessHandler implements OneTimeTokenGenerationSuccessHandler {
+
+ private final JavaMailSender mailSender;
+
+ public MagicLinkOneTimeTokenGenerationSuccessHandler(JavaMailSender mailSender) {
+ this.mailSender = mailSender;
+ }
+
+ @Override
+ public void handle(HttpServletRequest request, HttpServletResponse response, OneTimeToken oneTimeToken)
+ throws IOException {
+ SimpleMailMessage message = new SimpleMailMessage();
+ message.setFrom("noreply@example.com");
+ message.setTo("johndoe@example.com");
+ message.setSubject("Your token");
+ message.setText("Please enter this token " + oneTimeToken.getTokenValue());
+ this.mailSender.send(message);
+ response.sendRedirect("/login/ott");
+ }
+
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/resources/application.yml b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/resources/application.yml
new file mode 100644
index 000000000..cd1f80e4c
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/resources/application.yml
@@ -0,0 +1,13 @@
+spring:
+ application:
+ name: magiclink
+ mail:
+ port: 1025
+ host: localhost
+ docker:
+ compose:
+ readiness:
+ wait: never # for some reason it does not detect whether maildev is ready
+ file: ./compose.yml
+
+logging.level.org.springframework.security: TRACE
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/resources/static/css/default-ui.css b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/resources/static/css/default-ui.css
new file mode 100644
index 000000000..ec3d42bda
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/resources/static/css/default-ui.css
@@ -0,0 +1,172 @@
+/*
+ * 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.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/* General layout */
+body {
+ font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ background-color: #eee;
+ padding: 40px 0;
+ margin: 0;
+ line-height: 1.5;
+}
+
+h2 {
+ margin-top: 0;
+ margin-bottom: 0.5rem;
+ font-size: 2rem;
+ font-weight: 500;
+ line-height: 2rem;
+}
+
+.content {
+ margin-right: auto;
+ margin-left: auto;
+ padding-right: 15px;
+ padding-left: 15px;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+@media (min-width: 800px) {
+ .content {
+ max-width: 760px;
+ }
+}
+
+.v-middle {
+ vertical-align: middle;
+}
+
+.center {
+ text-align: center;
+}
+
+.no-margin {
+ margin: 0;
+}
+
+/* Components */
+a,
+a:visited {
+ text-decoration: none;
+ color: #06f;
+}
+
+a:hover {
+ text-decoration: underline;
+ color: #003c97;
+}
+
+input[type="text"],
+input[type="password"] {
+ height: auto;
+ width: 100%;
+ font-size: 1rem;
+ padding: 0.5rem;
+ box-sizing: border-box;
+}
+
+button {
+ padding: 0.5rem 1rem;
+ font-size: 1.25rem;
+ line-height: 1.5;
+ border: none;
+ border-radius: 0.1rem;
+ width: 100%;
+ cursor: pointer;
+}
+
+button.primary {
+ color: #fff;
+ background-color: #06f;
+}
+
+button.small {
+ padding: .25rem .5rem;
+ font-size: .875rem;
+ line-height: 1.5;
+}
+
+.alert {
+ padding: 0.75rem 1rem;
+ margin-bottom: 1rem;
+ line-height: 1.5;
+ border-radius: 0.1rem;
+ width: 100%;
+ box-sizing: border-box;
+ border-width: 1px;
+ border-style: solid;
+}
+
+.alert.alert-danger {
+ color: #6b1922;
+ background-color: #f7d5d7;
+ border-color: #eab6bb;
+}
+
+.alert.alert-success {
+ color: #145222;
+ background-color: #d1f0d9;
+ border-color: #c2ebcb;
+}
+
+.screenreader {
+ position: absolute;
+ clip: rect(0 0 0 0);
+ height: 1px;
+ width: 1px;
+ padding: 0;
+ border: 0;
+ overflow: hidden;
+}
+
+table {
+ width: 100%;
+ max-width: 100%;
+ margin-bottom: 2rem;
+ border-collapse: collapse;
+}
+
+.table-striped th {
+ padding: .75rem;
+}
+
+.table-striped tr:nth-of-type(2n + 1) {
+ background-color: #e1e1e1;
+}
+
+.table-striped > thead > tr:first-child {
+ background-color: inherit;
+}
+
+td {
+ padding: 0.75rem;
+ vertical-align: top;
+}
+
+tr.v-middle > td {
+ vertical-align: middle;
+}
+
+/* Login / logout layouts */
+.login-form,
+.logout-form,
+.default-form {
+ max-width: 340px;
+ padding: 0 15px 15px 15px;
+ margin: 0 auto 2rem auto;
+ box-sizing: border-box;
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/resources/templates/index.html b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/resources/templates/index.html
new file mode 100644
index 000000000..8457ea1d1
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/resources/templates/index.html
@@ -0,0 +1,20 @@
+
+
+
+ Hello Spring Security
+
+
+
+
+ Logged in user:
|
+ Roles:
+
+
+
+
+ Hello Spring Security
+ This is a secured page
+
+
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/resources/templates/login.html b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/resources/templates/login.html
new file mode 100644
index 000000000..7ce9bfe3e
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/resources/templates/login.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+ Please sign in
+
+
+
+
+
+
\ No newline at end of file
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/resources/templates/ott.html b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/resources/templates/ott.html
new file mode 100644
index 000000000..cde487a52
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/resources/templates/ott.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+ Please sign in
+
+
+
+
+
+
\ No newline at end of file
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/resources/templates/profile.html b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/resources/templates/profile.html
new file mode 100644
index 000000000..e60f88f84
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/main/resources/templates/profile.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+ Please sign in
+
+
+
+
+
This is a page that requires elevated security
+
+
+
\ No newline at end of file
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/test/java/org/example/magiclink/MagicLinkApplicationTests.java b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/test/java/org/example/magiclink/MagicLinkApplicationTests.java
new file mode 100644
index 000000000..fbde7025f
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/test/java/org/example/magiclink/MagicLinkApplicationTests.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 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.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.example.magiclink;
+
+import com.icegreen.greenmail.junit5.GreenMailExtension;
+import com.icegreen.greenmail.util.GreenMailUtil;
+import com.icegreen.greenmail.util.ServerSetupTest;
+import jakarta.mail.internet.MimeMessage;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.web.util.UriComponents;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
+import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@AutoConfigureMockMvc
+class MagicLinkApplicationTests {
+
+ @RegisterExtension
+ static GreenMailExtension greenMail = new GreenMailExtension(ServerSetupTest.SMTP);
+
+ @Autowired
+ MockMvc mockMvc;
+
+ @Test
+ void ottLoginWhenUserExistsThenSendEmailAndAuthenticate() throws Exception {
+ this.mockMvc.perform(post("/ott/generate").param("username", "user").with(csrf()))
+ .andExpectAll(status().isFound(), redirectedUrl("/ott/sent"));
+
+ greenMail.waitForIncomingEmail(1);
+ MimeMessage receivedMessage = greenMail.getReceivedMessages()[0];
+ String content = GreenMailUtil.getBody(receivedMessage);
+ String url = content.split(": ")[1];
+ UriComponents uriComponents = UriComponentsBuilder.fromUriString(url).build();
+ String token = uriComponents.getQueryParams().get("token").get(0);
+
+ assertThat(token).isNotEmpty();
+
+ this.mockMvc.perform(post("/login/ott").param("token", token).with(csrf()))
+ .andExpectAll(status().isFound(), redirectedUrl("/"), authenticated());
+ }
+
+ @Test
+ void ottLoginWhenInvalidTokenThenFails() throws Exception {
+ this.mockMvc.perform(post("/ott/generate").param("username", "user").with(csrf()))
+ .andExpectAll(status().isFound(), redirectedUrl("/ott/sent"));
+
+ String token = "1234;";
+
+ this.mockMvc.perform(post("/login/ott").param("token", token).with(csrf()))
+ .andExpectAll(status().isFound(), redirectedUrl("/login?error"), unauthenticated());
+ }
+
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/test/resources/application.yml b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/test/resources/application.yml
new file mode 100644
index 000000000..20f8e0227
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/formLogin+ott/src/test/resources/application.yml
@@ -0,0 +1,6 @@
+spring:
+ config:
+ import: classpath:application.yml
+ mail:
+ port: 3025
+ host: localhost
diff --git a/servlet/spring-boot/java/authentication/mfa/oauth2/.gitignore b/servlet/spring-boot/java/authentication/mfa/oauth2/.gitignore
new file mode 100644
index 000000000..c2065bc26
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/oauth2/.gitignore
@@ -0,0 +1,37 @@
+HELP.md
+.gradle
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+out/
+!**/src/main/**/out/
+!**/src/test/**/out/
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+
+### VS Code ###
+.vscode/
diff --git a/servlet/spring-boot/java/authentication/mfa/oauth2/README.md b/servlet/spring-boot/java/authentication/mfa/oauth2/README.md
new file mode 100644
index 000000000..fdf74d537
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/oauth2/README.md
@@ -0,0 +1,3 @@
+This application uses Spring Boot Docker Compose to start a [Maildev](https://github.com/maildev/maildev) container.
+
+After requesting a token on `http://localhost:8080/login`, access `http://localhost:1080` to verify the email containing the magic link.
diff --git a/servlet/spring-boot/java/authentication/mfa/oauth2/build.gradle b/servlet/spring-boot/java/authentication/mfa/oauth2/build.gradle
new file mode 100644
index 000000000..d19f311a2
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/oauth2/build.gradle
@@ -0,0 +1,34 @@
+plugins {
+ id 'java'
+ alias(libs.plugins.io.spring.dependency.management)
+ alias(libs.plugins.org.springframework.boot)
+}
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(17)
+ }
+}
+
+repositories {
+ mavenLocal()
+ mavenCentral()
+ maven { url "https://repo.spring.io/milestone" }
+ maven { url "https://repo.spring.io/snapshot" }
+}
+
+dependencies {
+ implementation 'org.springframework.boot:spring-boot-starter-security'
+ implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
+ implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'org.springframework.security:spring-security-test'
+ testImplementation 'com.icegreen:greenmail-junit5:2.0.1'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+}
+
+tasks.named('test') {
+ useJUnitPlatform()
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/oauth2/gradle.properties b/servlet/spring-boot/java/authentication/mfa/oauth2/gradle.properties
new file mode 100644
index 000000000..a5a6444df
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/oauth2/gradle.properties
@@ -0,0 +1,4 @@
+version=6.1.1
+spring-security.version=7.0.0-SNAPSHOT
+org.gradle.jvmargs=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError
+org.gradle.caching=true
diff --git a/servlet/spring-boot/java/authentication/mfa/oauth2/gradle/libs.versions.toml b/servlet/spring-boot/java/authentication/mfa/oauth2/gradle/libs.versions.toml
new file mode 120000
index 000000000..ebb52ed22
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/oauth2/gradle/libs.versions.toml
@@ -0,0 +1 @@
+../../../../../../../gradle/libs.versions.toml
\ No newline at end of file
diff --git a/servlet/spring-boot/java/authentication/mfa/oauth2/gradle/wrapper/gradle-wrapper.jar b/servlet/spring-boot/java/authentication/mfa/oauth2/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 000000000..e6441136f
Binary files /dev/null and b/servlet/spring-boot/java/authentication/mfa/oauth2/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/servlet/spring-boot/java/authentication/mfa/oauth2/gradle/wrapper/gradle-wrapper.properties b/servlet/spring-boot/java/authentication/mfa/oauth2/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000..a4413138c
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/oauth2/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/servlet/spring-boot/java/authentication/mfa/oauth2/gradlew b/servlet/spring-boot/java/authentication/mfa/oauth2/gradlew
new file mode 100755
index 000000000..b740cf133
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/oauth2/gradlew
@@ -0,0 +1,249 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/servlet/spring-boot/java/authentication/mfa/oauth2/gradlew.bat b/servlet/spring-boot/java/authentication/mfa/oauth2/gradlew.bat
new file mode 100644
index 000000000..25da30dbd
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/oauth2/gradlew.bat
@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/servlet/spring-boot/java/authentication/mfa/oauth2/settings.gradle b/servlet/spring-boot/java/authentication/mfa/oauth2/settings.gradle
new file mode 100644
index 000000000..733fda690
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/oauth2/settings.gradle
@@ -0,0 +1,8 @@
+pluginManagement {
+ repositories {
+ mavenCentral()
+ gradlePluginPortal()
+ maven { url 'https://repo.spring.io/milestone' }
+ maven { url "https://repo.spring.io/snapshot" }
+ }
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/oauth2/src/main/java/org/example/magiclink/FormLoginOAuth2Application.java b/servlet/spring-boot/java/authentication/mfa/oauth2/src/main/java/org/example/magiclink/FormLoginOAuth2Application.java
new file mode 100644
index 000000000..a92db69ba
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/oauth2/src/main/java/org/example/magiclink/FormLoginOAuth2Application.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 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.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.example.magiclink;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.provisioning.InMemoryUserDetailsManager;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+
+@SpringBootApplication
+public class FormLoginOAuth2Application {
+
+ public static void main(String[] args) {
+ SpringApplication.run(FormLoginOAuth2Application.class, args);
+ }
+
+ @Controller
+ static class AppController {
+ @GetMapping("/profile")
+ String profile() {
+ return "profile";
+ }
+ }
+
+ @Bean
+ InMemoryUserDetailsManager userDetailsService() {
+ UserDetails user = User.withDefaultPasswordEncoder()
+ .username("user")
+ .password("password")
+ .roles("USER")
+ .build();
+ return new InMemoryUserDetailsManager(user);
+ }
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/oauth2/src/main/java/org/example/magiclink/SecurityConfig.java b/servlet/spring-boot/java/authentication/mfa/oauth2/src/main/java/org/example/magiclink/SecurityConfig.java
new file mode 100644
index 000000000..5179040f0
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/oauth2/src/main/java/org/example/magiclink/SecurityConfig.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 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.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.example.magiclink;
+
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.authorization.AuthorizationRequest;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.oauth2.client.CommonOAuth2Provider;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
+import org.springframework.security.oauth2.client.authentication.OAuth2LoginAuthenticationToken;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
+import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
+import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;
+import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
+import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository;
+import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
+import org.springframework.security.web.AuthorizationEntryPoint;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.stereotype.Component;
+
+import static org.springframework.security.oauth2.core.authorization.OAuth2AuthorizationManagers.hasScope;
+
+@Configuration(proxyBeanMethods = false)
+class SecurityConfig {
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http, OAuth2ScopeAuthorizationEntryPoint oauth2) throws Exception {
+ // @formatter:off
+ http
+ .authorizeHttpRequests((authz) -> authz
+ .requestMatchers("/profile").access(hasScope("https://www.googleapis.com/auth/gmail.readonly"))
+ .anyRequest().authenticated()
+ )
+ .oauth2Login(Customizer.withDefaults())
+ .exceptionHandling((exceptions) -> exceptions.authorizationEntryPoint((a) -> a.add(oauth2)));
+ // @formatter:on
+ return http.build();
+ }
+
+ @Bean
+ ClientRegistrationRepository clients() {
+ ClientRegistration google = CommonOAuth2Provider.GOOGLE.getBuilder("google")
+ .clientId(System.getenv("GOOGLE_CLIENT_ID"))
+ .clientSecret(System.getenv("GOOGLE_CLIENT_SECRET"))
+ .scope("openid", "profile", "email", "https://www.googleapis.com/auth/gmail.readonly")
+ .build();
+ return new InMemoryClientRegistrationRepository(google);
+ }
+
+ @Component
+ static class OAuth2ScopeAuthorizationEntryPoint implements AuthorizationEntryPoint {
+
+ private final ClientRegistration google;
+
+ private final OAuth2AuthorizationRequestResolver authorizationRequestResolver;
+
+ private final AuthorizationRequestRepository authorizationRequestRepository =
+ new HttpSessionOAuth2AuthorizationRequestRepository();
+
+ public OAuth2ScopeAuthorizationEntryPoint(ClientRegistrationRepository clients) {
+ this.google = clients.findByRegistrationId("google");
+ this.authorizationRequestResolver = new DefaultOAuth2AuthorizationRequestResolver(clients);
+ }
+
+ @Override
+ public boolean commence(HttpServletRequest request, HttpServletResponse response, AuthorizationRequest authorizationRequest) throws IOException, ServletException {
+ Set needed = AuthorityUtils.authorityListToSet(authorizationRequest.getAuthorities());
+ Set scopes = new HashSet<>();
+ for (String scope : needed) {
+ if (scope.startsWith("SCOPE_")) {
+ scopes.add(scope.substring("SCOPE_".length()));
+ }
+ }
+ if (scopes.isEmpty()) {
+ return false;
+ }
+ OAuth2AuthorizationRequest oauth2 = this.authorizationRequestResolver.resolve(request, this.google.getRegistrationId());
+ oauth2 = OAuth2AuthorizationRequest.from(oauth2).scopes(scopes).build();
+ this.authorizationRequestRepository.saveAuthorizationRequest(oauth2, request, response);
+ response.sendRedirect(oauth2.getAuthorizationRequestUri());
+ return true;
+ }
+ }
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/oauth2/src/main/resources/application.yml b/servlet/spring-boot/java/authentication/mfa/oauth2/src/main/resources/application.yml
new file mode 100644
index 000000000..d7d92ebab
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/oauth2/src/main/resources/application.yml
@@ -0,0 +1,5 @@
+spring:
+ application:
+ name: formLogin+oauth2
+
+logging.level.org.springframework.security: TRACE
diff --git a/servlet/spring-boot/java/authentication/mfa/oauth2/src/main/resources/static/css/default-ui.css b/servlet/spring-boot/java/authentication/mfa/oauth2/src/main/resources/static/css/default-ui.css
new file mode 100644
index 000000000..ec3d42bda
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/oauth2/src/main/resources/static/css/default-ui.css
@@ -0,0 +1,172 @@
+/*
+ * 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.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/* General layout */
+body {
+ font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ background-color: #eee;
+ padding: 40px 0;
+ margin: 0;
+ line-height: 1.5;
+}
+
+h2 {
+ margin-top: 0;
+ margin-bottom: 0.5rem;
+ font-size: 2rem;
+ font-weight: 500;
+ line-height: 2rem;
+}
+
+.content {
+ margin-right: auto;
+ margin-left: auto;
+ padding-right: 15px;
+ padding-left: 15px;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+@media (min-width: 800px) {
+ .content {
+ max-width: 760px;
+ }
+}
+
+.v-middle {
+ vertical-align: middle;
+}
+
+.center {
+ text-align: center;
+}
+
+.no-margin {
+ margin: 0;
+}
+
+/* Components */
+a,
+a:visited {
+ text-decoration: none;
+ color: #06f;
+}
+
+a:hover {
+ text-decoration: underline;
+ color: #003c97;
+}
+
+input[type="text"],
+input[type="password"] {
+ height: auto;
+ width: 100%;
+ font-size: 1rem;
+ padding: 0.5rem;
+ box-sizing: border-box;
+}
+
+button {
+ padding: 0.5rem 1rem;
+ font-size: 1.25rem;
+ line-height: 1.5;
+ border: none;
+ border-radius: 0.1rem;
+ width: 100%;
+ cursor: pointer;
+}
+
+button.primary {
+ color: #fff;
+ background-color: #06f;
+}
+
+button.small {
+ padding: .25rem .5rem;
+ font-size: .875rem;
+ line-height: 1.5;
+}
+
+.alert {
+ padding: 0.75rem 1rem;
+ margin-bottom: 1rem;
+ line-height: 1.5;
+ border-radius: 0.1rem;
+ width: 100%;
+ box-sizing: border-box;
+ border-width: 1px;
+ border-style: solid;
+}
+
+.alert.alert-danger {
+ color: #6b1922;
+ background-color: #f7d5d7;
+ border-color: #eab6bb;
+}
+
+.alert.alert-success {
+ color: #145222;
+ background-color: #d1f0d9;
+ border-color: #c2ebcb;
+}
+
+.screenreader {
+ position: absolute;
+ clip: rect(0 0 0 0);
+ height: 1px;
+ width: 1px;
+ padding: 0;
+ border: 0;
+ overflow: hidden;
+}
+
+table {
+ width: 100%;
+ max-width: 100%;
+ margin-bottom: 2rem;
+ border-collapse: collapse;
+}
+
+.table-striped th {
+ padding: .75rem;
+}
+
+.table-striped tr:nth-of-type(2n + 1) {
+ background-color: #e1e1e1;
+}
+
+.table-striped > thead > tr:first-child {
+ background-color: inherit;
+}
+
+td {
+ padding: 0.75rem;
+ vertical-align: top;
+}
+
+tr.v-middle > td {
+ vertical-align: middle;
+}
+
+/* Login / logout layouts */
+.login-form,
+.logout-form,
+.default-form {
+ max-width: 340px;
+ padding: 0 15px 15px 15px;
+ margin: 0 auto 2rem auto;
+ box-sizing: border-box;
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/oauth2/src/main/resources/templates/index.html b/servlet/spring-boot/java/authentication/mfa/oauth2/src/main/resources/templates/index.html
new file mode 100644
index 000000000..8457ea1d1
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/oauth2/src/main/resources/templates/index.html
@@ -0,0 +1,20 @@
+
+
+
+ Hello Spring Security
+
+
+
+
+ Logged in user:
|
+ Roles:
+
+
+
+
+ Hello Spring Security
+ This is a secured page
+
+
diff --git a/servlet/spring-boot/java/authentication/mfa/oauth2/src/main/resources/templates/login.html b/servlet/spring-boot/java/authentication/mfa/oauth2/src/main/resources/templates/login.html
new file mode 100644
index 000000000..7ce9bfe3e
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/oauth2/src/main/resources/templates/login.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+ Please sign in
+
+
+
+
+
+
\ No newline at end of file
diff --git a/servlet/spring-boot/java/authentication/mfa/oauth2/src/main/resources/templates/profile.html b/servlet/spring-boot/java/authentication/mfa/oauth2/src/main/resources/templates/profile.html
new file mode 100644
index 000000000..e60f88f84
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/oauth2/src/main/resources/templates/profile.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+ Please sign in
+
+
+
+
+
This is a page that requires elevated security
+
+
+
\ No newline at end of file
diff --git a/servlet/spring-boot/java/authentication/mfa/oauth2/src/test/java/org/example/magiclink/FormLoginOAuth2ApplicationTests.java b/servlet/spring-boot/java/authentication/mfa/oauth2/src/test/java/org/example/magiclink/FormLoginOAuth2ApplicationTests.java
new file mode 100644
index 000000000..beb09e0cf
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/oauth2/src/test/java/org/example/magiclink/FormLoginOAuth2ApplicationTests.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 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.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.example.magiclink;
+
+import com.icegreen.greenmail.junit5.GreenMailExtension;
+import com.icegreen.greenmail.util.GreenMailUtil;
+import com.icegreen.greenmail.util.ServerSetupTest;
+import jakarta.mail.internet.MimeMessage;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.web.util.UriComponents;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
+import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@AutoConfigureMockMvc
+class FormLoginOAuth2ApplicationTests {
+
+ @RegisterExtension
+ static GreenMailExtension greenMail = new GreenMailExtension(ServerSetupTest.SMTP);
+
+ @Autowired
+ MockMvc mockMvc;
+
+ @Test
+ void ottLoginWhenUserExistsThenSendEmailAndAuthenticate() throws Exception {
+ this.mockMvc.perform(post("/ott/generate").param("username", "user").with(csrf()))
+ .andExpectAll(status().isFound(), redirectedUrl("/ott/sent"));
+
+ greenMail.waitForIncomingEmail(1);
+ MimeMessage receivedMessage = greenMail.getReceivedMessages()[0];
+ String content = GreenMailUtil.getBody(receivedMessage);
+ String url = content.split(": ")[1];
+ UriComponents uriComponents = UriComponentsBuilder.fromUriString(url).build();
+ String token = uriComponents.getQueryParams().get("token").get(0);
+
+ assertThat(token).isNotEmpty();
+
+ this.mockMvc.perform(post("/login/ott").param("token", token).with(csrf()))
+ .andExpectAll(status().isFound(), redirectedUrl("/"), authenticated());
+ }
+
+ @Test
+ void ottLoginWhenInvalidTokenThenFails() throws Exception {
+ this.mockMvc.perform(post("/ott/generate").param("username", "user").with(csrf()))
+ .andExpectAll(status().isFound(), redirectedUrl("/ott/sent"));
+
+ String token = "1234;";
+
+ this.mockMvc.perform(post("/login/ott").param("token", token).with(csrf()))
+ .andExpectAll(status().isFound(), redirectedUrl("/login?error"), unauthenticated());
+ }
+
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/oauth2/src/test/resources/application.yml b/servlet/spring-boot/java/authentication/mfa/oauth2/src/test/resources/application.yml
new file mode 100644
index 000000000..20f8e0227
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/oauth2/src/test/resources/application.yml
@@ -0,0 +1,6 @@
+spring:
+ config:
+ import: classpath:application.yml
+ mail:
+ port: 3025
+ host: localhost
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/README.adoc b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/README.adoc
new file mode 100644
index 000000000..5e5ca4d81
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/README.adoc
@@ -0,0 +1,71 @@
+= X.509 + Form Login MFA Sample
+
+This sample demonstrates configuring Spring Security to require both an X.509 Certificate and a Username/Password Login in order to enter the site with full permissions.
+
+== Preparing to Use X.509
+
+This sample is intended to be used in a browser.
+As such, you should:
+
+1. Configure your browser to trust the `ca.crt` that accompanies this project
+2. Configure your browser with the `josh-keystore.p12` client certificate
+
+Both `api-keystore.p12` and `josh-keystore.p12` use keys signed by `ca.crt`.
+This means that after the above steps are performed, you can also use this application without getting a security warning in your browser.
+
+== Using the Sample
+
+To run, please use:
+
+.Java
+[source,java,role="primary"]
+----
+./gradlew :bootRun
+----
+
+This will start an application on 8443, meaning you will need to reach it using HTTPS.
+
+You can reach the website at https://api.127.0.0.1.nip.io:8443.
+If that isn't working for you, please try https://localhost:8443.
+
+With the client certificate (`josh-keystore.p12`) correctly installed in the browser, it will ask you which client certificate you want to you.
+Select `josh`.
+
+You will then be redirected to the login page where you can use `josh/password` as the username and password.
+
+== Exploring the Sample
+
+The key configuration is found in the `HttpSecurity` DSL:
+
+.Java
+[source,java,role="primary"]
+----
+http
+ .x509((x509) -> x509.grants("form:read"))
+ .formLogin((form) -> form.needs("form:read").authenticates())
+----
+
+This reads, "X.509 grants the authority to go to the login page, Form Login grants the authority to fully log in".
+
+There is no inherent meaning in the authority names.
+So, you can also do:
+
+.Java
+[source,java,role="primary"]
+----
+http
+ .x509((x509) -> x509.grants("form:authenticate"))
+ .formLogin((form) -> form.needs("from:authenticate").authenticates())
+----
+
+You can instead try another arrangement like the following:
+
+.Java
+[source,java,role="primary"]
+----
+http
+ .x509((x509) -> x509.grants("ott:read"))
+ .oneTimeTokenLogin((ott) -> ott.needs("ott:read").authenticates())
+----
+
+Once `oneTimeTokenLogin` is correctly configured and once a client certificate is accepted, the application will generate a token and send it to the configured destination to continue with the login process.
\ No newline at end of file
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/build.gradle b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/build.gradle
new file mode 100644
index 000000000..5352be8d5
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/build.gradle
@@ -0,0 +1,31 @@
+plugins {
+ alias(libs.plugins.io.spring.dependency.management)
+ alias(libs.plugins.org.springframework.boot)
+ id "nebula.integtest" version "8.2.0"
+ id 'java'
+}
+
+repositories {
+ mavenLocal()
+ mavenCentral()
+ maven { url "https://repo.spring.io/milestone" }
+ maven { url "https://repo.spring.io/snapshot" }
+}
+
+
+dependencies {
+ implementation 'org.springframework.boot:spring-boot-starter-security'
+ implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.security:spring-security-crypto'
+
+ implementation 'com.j256.two-factor-auth:two-factor-auth:1.3'
+
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'org.springframework.security:spring-security-test'
+}
+
+tasks.withType(Test).configureEach {
+ useJUnitPlatform()
+
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/add-to-keystore b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/add-to-keystore
new file mode 100755
index 000000000..cb133de05
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/add-to-keystore
@@ -0,0 +1,44 @@
+#!/bin/bash
+set -euo pipefail
+
+KEYSTORE="${1:-}"
+if [[ -z "$KEYSTORE" ]]; then
+ echo "Usage: $0 " >&2
+ exit 1
+fi
+
+PASSWORD="password"
+
+# Set up temp workspace
+WORKDIR=$(mktemp -d)
+trap "rm -rf $WORKDIR" EXIT
+
+# Read input tar archive from stdin
+tar -C "$WORKDIR" -xf -
+
+ALIAS=$(cat "$WORKDIR/alias")
+CERT="$WORKDIR/cert.pem"
+KEY="$WORKDIR/key.pem"
+CHAIN="$WORKDIR/chain.pem"
+
+# Convert to PKCS#12 bundle
+PKCS12="$WORKDIR/temp.p12"
+openssl pkcs12 -export \
+ -inkey "$KEY" \
+ -in "$CERT" \
+ -certfile "$CHAIN" \
+ -name "$ALIAS" \
+ -out "$PKCS12" \
+ -passout pass:$PASSWORD
+
+# If alias exists, delete it
+if [[ -f "$KEYSTORE" ]]; then
+ keytool -delete -alias "$ALIAS" -keystore "$KEYSTORE" \
+ -storepass "$PASSWORD" -storetype PKCS12 || true
+fi
+
+# Import new entry
+keytool -importkeystore \
+ -destkeystore "$KEYSTORE" -deststoretype PKCS12 -deststorepass "$PASSWORD" \
+ -srckeystore "$PKCS12" -srcstoretype PKCS12 -srcstorepass "$PASSWORD" \
+ -alias "$ALIAS"
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/add-to-truststore b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/add-to-truststore
new file mode 100755
index 000000000..0f7393074
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/add-to-truststore
@@ -0,0 +1,38 @@
+#!/bin/bash
+set -euo pipefail
+
+TRUSTSTORE="${1:-}"
+if [[ -z "$TRUSTSTORE" ]]; then
+ echo "Usage: $0 " >&2
+ exit 1
+fi
+
+PASSWORD="password"
+
+# Temp workspace
+WORKDIR=$(mktemp -d)
+trap "rm -rf $WORKDIR" EXIT
+
+# Extract from tar input
+tar -C "$WORKDIR" -xf -
+
+ALIAS=$(cat "$WORKDIR/alias")
+CA_CERT="$WORKDIR/ca.pem"
+DER_CERT="$WORKDIR/ca.der"
+
+# Convert to DER format for keytool
+openssl x509 -in "$CA_CERT" -outform DER -out "$DER_CERT"
+
+# If alias exists, delete
+if [[ -f "$TRUSTSTORE" ]]; then
+ keytool -delete -alias "$ALIAS" -keystore "$TRUSTSTORE" \
+ -storepass "$PASSWORD" -storetype PKCS12 || true
+fi
+
+# Import into truststore
+keytool -importcert -noprompt \
+ -alias "$ALIAS" \
+ -file "$DER_CERT" \
+ -keystore "$TRUSTSTORE" \
+ -storetype PKCS12 \
+ -storepass "$PASSWORD"
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/api-keystore.p12 b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/api-keystore.p12
new file mode 100644
index 000000000..b36b72752
Binary files /dev/null and b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/api-keystore.p12 differ
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/api-truststore.p12 b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/api-truststore.p12
new file mode 100644
index 000000000..56281f545
Binary files /dev/null and b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/api-truststore.p12 differ
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/ca.crt b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/ca.crt
new file mode 100644
index 000000000..4c893d45d
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/ca.crt
@@ -0,0 +1,30 @@
+-----BEGIN CERTIFICATE-----
+MIIFDzCCAvegAwIBAgIUHzvf6/d0vmFFEYh5GHA/I8t787YwDQYJKoZIhvcNAQEL
+BQAwFzEVMBMGA1UEAwwMTG9jYWwgRGV2IENBMB4XDTI1MDcxMjE4MDM0NFoXDTM1
+MDcxMDE4MDM0NFowFzEVMBMGA1UEAwwMTG9jYWwgRGV2IENBMIICIjANBgkqhkiG
+9w0BAQEFAAOCAg8AMIICCgKCAgEAxezzB+gvNL5WeuzVvvlv21OvbbJtdP6k08Yz
+egIddIckQ2Yguuc5gHoUnNve5BVEQZmFNQihqLlzV0lFb+4QjqiH5BWKql+U7TRG
+GCLecF4VQHup5voQv6EUnVOJglc13NAjWqbP09M5aIxPNzfRv3eeZq2jLXu3aQ6Q
++mFq/cIGSlHF6KjqTTXoK1sRngZUeZi1/fhg1/9usH4L5nQ41y3D+51c5Zl+UW3w
+7bR70XkH3aX6X4xOOfZBiyp3wxrG7uQBddQAk2zu/hMTYWNAYswT15jIEk4JLH7o
+HXrVaoM5vnL0D5SaIc66JnAhwyC3E1jc7OpioFCO0Xi5XZAt3s+E0AwOpAvSomIH
+FdryfEHcbhqSUgUVof/c9PmLwtS2fVPm3LqqLjmwagO9DPDCGroNwoOKitfoH3qQ
+EtETygsKThMYLc23tLbyYsjPs5H8ro+s41aqcsCiQIhELRXwZVyCWj53o8tE4Ihh
+hdTzbzNmrBdv0H9vb3VS9SdfHYFJ409qIu+kkmsAQ8vd6e92UJXbKd/Vchmr2ogs
+7oGLIOID9KZMqe4dKKzU+ietLz0IVTWRaPz3ea7NGRIo3/yxGDMRF/Z0UwH53H+E
+crn8w/aMWeIqMq5h6sC24D9RkcVN+E5QzxvG2VS4A7CzskIrduAWl5TrBERChbiX
+wQusjYcCAwEAAaNTMFEwHQYDVR0OBBYEFLq0yPS9u7Flbh061DkXX79lvNltMB8G
+A1UdIwQYMBaAFLq0yPS9u7Flbh061DkXX79lvNltMA8GA1UdEwEB/wQFMAMBAf8w
+DQYJKoZIhvcNAQELBQADggIBABMh+cLO2ge0FKNkJriiBXs3ah6w/GGWVgFK6BmU
+297fM9FSV/1bQcTUe1gpudabGRsq7TthC/aK3B+79tsfudcrPKJZrK7cFkxY06xT
+3el45RQxZNYvaHRsK7nw6gExCLlYFKAatHcdvbk6xe+XZAAr4rLYg1H1n7Ddg3p/
+K3l0a/6nAyMND7T1euzijR+40EZdiXzwsgFR3R2YIWiNmLu/z3tg0HfYIgrQaOou
+Xl06qXZ1iJW1EFuF/KoxNBxyJQpHywYTvb6UuGV2UOvI3V7SzzgyBvBxOUf4djNM
+kEK1+U5lnMqSDgHWkEzNUFMlsIwIiEWSMo+deF+/9FiAQxUX85kH9O8/7t9rqzxK
+mgVccTnzvEcYyRYgL1SsCXo1oJfZ8M4OnZ/dDY0y86kngrgkIxFpPPAh0VVpyVGn
+A9CyYU7vQxTrgwHtqITMN7kNBWEAZgA82kURbZm/DDoKX5IJdcMdwvFaVk2irSyQ
+x0Px+k5eNhSGdKctXESAAUeUADpaYhRZeRI7jJDPCJGvs5or4hiA/3O8ZI5Lum12
+fKNSau4m3jbTWrD4x7zlbVES9hRB87f/uST0/yh4NfdjrspzNINWXArxPDsYs7Sf
+HDVj3RPNKVGzJEH4+J+Ohti3JTEl8hLBO3ZoMJ2uPQ44wcJAcKVL7O3QraKOfzXu
+k1d0
+-----END CERTIFICATE-----
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/ca.key b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/ca.key
new file mode 100644
index 000000000..653f8d1c1
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/ca.key
@@ -0,0 +1,52 @@
+-----BEGIN PRIVATE KEY-----
+MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQDF7PMH6C80vlZ6
+7NW++W/bU69tsm10/qTTxjN6Ah10hyRDZiC65zmAehSc297kFURBmYU1CKGouXNX
+SUVv7hCOqIfkFYqqX5TtNEYYIt5wXhVAe6nm+hC/oRSdU4mCVzXc0CNaps/T0zlo
+jE83N9G/d55mraMte7dpDpD6YWr9wgZKUcXoqOpNNegrWxGeBlR5mLX9+GDX/26w
+fgvmdDjXLcP7nVzlmX5RbfDttHvReQfdpfpfjE459kGLKnfDGsbu5AF11ACTbO7+
+ExNhY0BizBPXmMgSTgksfugdetVqgzm+cvQPlJohzromcCHDILcTWNzs6mKgUI7R
+eLldkC3ez4TQDA6kC9KiYgcV2vJ8QdxuGpJSBRWh/9z0+YvC1LZ9U+bcuqouObBq
+A70M8MIaug3Cg4qK1+gfepAS0RPKCwpOExgtzbe0tvJiyM+zkfyuj6zjVqpywKJA
+iEQtFfBlXIJaPnejy0TgiGGF1PNvM2asF2/Qf29vdVL1J18dgUnjT2oi76SSawBD
+y93p73ZQldsp39VyGavaiCzugYsg4gP0pkyp7h0orNT6J60vPQhVNZFo/Pd5rs0Z
+Eijf/LEYMxEX9nRTAfncf4RyufzD9oxZ4ioyrmHqwLbgP1GRxU34TlDPG8bZVLgD
+sLOyQit24BaXlOsEREKFuJfBC6yNhwIDAQABAoICAAkuOFFZt0f44C7ioIpYLG/r
+hMu9XbPNr/NCLdcTp3eJzuhej1eFLCShTiaaLM+H1ZH00XDMu9ZOYqw5X7Anj3Hn
+r5cWEUfLPJ1Te0DZWcVqh8RlOAq0QByhF676w7EUdaNFZNL85hhdWjvpW+Yj7WW2
+T/rBRIVgSB3BzeyWD+lmLdHxiexgEWYiAURys181gxlLpjRDHzv8edhT50LVZPPX
+UXTeeHEOfynjeuL7uk1m7Vd5m9mTllS8gY83YuwmjM/0vOm+qKnkyg9mE2Z8Bjbz
+hKQYvIcAQsYaBEXvf1wgtBpCX5rbHmzT4Kf66qqP8Fcf0xL4c6b3I1QtdTXwg63N
+nKB7IeZTeVI2k0UF4Rme4tDl9LzlhiR7SRMd76e+8684GmUAXsF7TEdXIPAlQV9y
+GsF+dit16nMZPMIhWT+M1MOV94eVLu2ncEYP1dR4bZeFprxmv6DxgtxczqVyJQ9c
+4t2ujnekssxLHnkpOrV4CbkAuD3TUJsEs3359HmjGLS+VnGJrbvjILMbqgvN5hB5
+A37Rfals4f+O0MVAyV8sToqcTv2tGORe3aIl1JeExElTMl8ij+a9sRWwUDic1xTI
+F6YsRdyWLrCpwe00mlmb+ZlHqDObFbTHTKpUQoqxKUxn3uxsxPK6sLFjLg3sCa6X
+kuUS88uZwk76l3pGwltpAoIBAQDnNx80QvApz5M4/jp6vE6sAgyyDGwoqX1850ly
+tzCiCbKC0/9uVF2wpcRScjZOjIkdQ6ROKTDZiC9fFMbdbORhCjKa1fG9OKCIr2uQ
+KKgyhtjBSyzCMpFX7djbZrkSNVAGsvlTNrg2ANUoy9amm5rJoXJfuhDwdgc50uey
+NHU6CmAWIaBUHlpEtV9ABURLh8Ipj+4QiK4ufsOJgUWNWwoaSL73x4HKVPrG7jc0
+EJ/U9oIGvSjE0Q41u6y+9LODSteN4gkp4dcHWpEJnk/xUWOojT/kbKjKfzKLTajA
++1KmLR1piAH59gx3C6zwRllFJBoB9a030cdIjU9a0XypSu+lAoIBAQDbJE9CNfRr
+es1vAA9ukNvpF6WLg8TTr1VR5mSweb4SGIlKqe+5kd87c73EhJYiE0tr7ZsqHKCF
+p6OqvhV65ICl6214Hd4qV+J5rPxWUTEs7SWxdl2JVmCGZpn3nFWFwF7DQfJYihX3
+D9HyMpzJg2HLZRv29WFrhQ4JdTdPi31/UW17ECUkqal9Z7XZSVvWR3SfbUImrI9x
+eX7SqKusindv974obJFEo01UmFITwVtInSf1T+Xc3twvqOSZD7DnABJx68vUwxyo
+EZlPKKT1fCNXlFUKQoPmG+y742IThAPiN89UfDahbecJ+HZUrfrK8UUDcVV5KkBH
+9CSFb+kHGYC7AoIBAG46rTmxH+YO+9UT/rU8yRTf9UV8/qN0CktdyHpUM29MyDnu
+77udpPzuSmYz5QgVn9i/wrkwkgVjE5J0yUoO++H3hqCilpjrQj1nxBP6DhXoi7W7
+LR94FCqjTdtrYZf4qqpG8O5nC/NS+kx0wWS0klrGCUzx29mHq3I5xhQDRk/hWmWy
+qkjwH4DaJwrSd/i6RCqkX46qWr/31yja5Fm7qVlWjRR7nLjlQplMQC0mL8zLqLml
+vKX4NJoRWw2+g0Z4i8Msm8nHzUfIOZUoUFxvvN9CV8+CrgW8FlCrOWSnbIOkxnzl
+RmvwjYjDnDMAltaLm4qLoYUXEbbZB5f4f0IGY7ECggEARS38O2mvBHMbAUyikoP2
+eGo3n4h0jWMPazBxXui/4RSP2ts0y39KWolaQfydLJqst6Cl2DB7WFYoq9EgFNCn
+8DkXMNE0/mcKHuFGM7Wj8YvX12MHekCjbipbtrhKo1OsVrWt3NeSwZDj9TKXHmJ0
+b/I2Vsr1+yxg1wmC8YCWmKfLCQt6vk01LVqdJMAs1sNuBJpIRM865Va2e6g1sd1w
+gQ9Tn41Oer2WvvrrBkOHHrBGGgIkDYrpNb56k/tJHFOAfygyC7Ogi0oq/LtXAAw1
+WAOCqR+AZhcwr8vDfWeyliqKMCCaWnHIevRN3sOhpYlvAPw5QGvfKRfgo6NFjDE3
+2wKCAQEAkd3F5feBAPiGr354kyGmKGSUC1KKmaNCQCthPxt6Wb0QRHBa4yj6sHf5
+YcoL0WLFEIftKFDsbCMPLSbLE2SvsWFQmHu7L4B7xdd+0jcAcM5eOBcTqSptCc/v
+uVMRANhYlgXQqtPHp1q2uBkC2++aE/yuGmrngyIkwP9ewyzLmsZBSpIrGB//qav6
+DzRATKwF9YI6+v67CFkBLSQ5JES07FWxQSp3aMHCiuGsKfOk+wj2yhMi7KMHZA82
+0fJ7c7heUedFu3+Gt5nJR/bdgC7Tva9iDo/UC7FTmMF0ZU18fXAtzDh2pYoTm3o3
+UZaWgz2MB+hNvTCg+8toreA0o4/SiA==
+-----END PRIVATE KEY-----
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/ca.pem b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/ca.pem
new file mode 100644
index 000000000..4c893d45d
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/ca.pem
@@ -0,0 +1,30 @@
+-----BEGIN CERTIFICATE-----
+MIIFDzCCAvegAwIBAgIUHzvf6/d0vmFFEYh5GHA/I8t787YwDQYJKoZIhvcNAQEL
+BQAwFzEVMBMGA1UEAwwMTG9jYWwgRGV2IENBMB4XDTI1MDcxMjE4MDM0NFoXDTM1
+MDcxMDE4MDM0NFowFzEVMBMGA1UEAwwMTG9jYWwgRGV2IENBMIICIjANBgkqhkiG
+9w0BAQEFAAOCAg8AMIICCgKCAgEAxezzB+gvNL5WeuzVvvlv21OvbbJtdP6k08Yz
+egIddIckQ2Yguuc5gHoUnNve5BVEQZmFNQihqLlzV0lFb+4QjqiH5BWKql+U7TRG
+GCLecF4VQHup5voQv6EUnVOJglc13NAjWqbP09M5aIxPNzfRv3eeZq2jLXu3aQ6Q
++mFq/cIGSlHF6KjqTTXoK1sRngZUeZi1/fhg1/9usH4L5nQ41y3D+51c5Zl+UW3w
+7bR70XkH3aX6X4xOOfZBiyp3wxrG7uQBddQAk2zu/hMTYWNAYswT15jIEk4JLH7o
+HXrVaoM5vnL0D5SaIc66JnAhwyC3E1jc7OpioFCO0Xi5XZAt3s+E0AwOpAvSomIH
+FdryfEHcbhqSUgUVof/c9PmLwtS2fVPm3LqqLjmwagO9DPDCGroNwoOKitfoH3qQ
+EtETygsKThMYLc23tLbyYsjPs5H8ro+s41aqcsCiQIhELRXwZVyCWj53o8tE4Ihh
+hdTzbzNmrBdv0H9vb3VS9SdfHYFJ409qIu+kkmsAQ8vd6e92UJXbKd/Vchmr2ogs
+7oGLIOID9KZMqe4dKKzU+ietLz0IVTWRaPz3ea7NGRIo3/yxGDMRF/Z0UwH53H+E
+crn8w/aMWeIqMq5h6sC24D9RkcVN+E5QzxvG2VS4A7CzskIrduAWl5TrBERChbiX
+wQusjYcCAwEAAaNTMFEwHQYDVR0OBBYEFLq0yPS9u7Flbh061DkXX79lvNltMB8G
+A1UdIwQYMBaAFLq0yPS9u7Flbh061DkXX79lvNltMA8GA1UdEwEB/wQFMAMBAf8w
+DQYJKoZIhvcNAQELBQADggIBABMh+cLO2ge0FKNkJriiBXs3ah6w/GGWVgFK6BmU
+297fM9FSV/1bQcTUe1gpudabGRsq7TthC/aK3B+79tsfudcrPKJZrK7cFkxY06xT
+3el45RQxZNYvaHRsK7nw6gExCLlYFKAatHcdvbk6xe+XZAAr4rLYg1H1n7Ddg3p/
+K3l0a/6nAyMND7T1euzijR+40EZdiXzwsgFR3R2YIWiNmLu/z3tg0HfYIgrQaOou
+Xl06qXZ1iJW1EFuF/KoxNBxyJQpHywYTvb6UuGV2UOvI3V7SzzgyBvBxOUf4djNM
+kEK1+U5lnMqSDgHWkEzNUFMlsIwIiEWSMo+deF+/9FiAQxUX85kH9O8/7t9rqzxK
+mgVccTnzvEcYyRYgL1SsCXo1oJfZ8M4OnZ/dDY0y86kngrgkIxFpPPAh0VVpyVGn
+A9CyYU7vQxTrgwHtqITMN7kNBWEAZgA82kURbZm/DDoKX5IJdcMdwvFaVk2irSyQ
+x0Px+k5eNhSGdKctXESAAUeUADpaYhRZeRI7jJDPCJGvs5or4hiA/3O8ZI5Lum12
+fKNSau4m3jbTWrD4x7zlbVES9hRB87f/uST0/yh4NfdjrspzNINWXArxPDsYs7Sf
+HDVj3RPNKVGzJEH4+J+Ohti3JTEl8hLBO3ZoMJ2uPQ44wcJAcKVL7O3QraKOfzXu
+k1d0
+-----END CERTIFICATE-----
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/ca.srl b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/ca.srl
new file mode 100644
index 000000000..8b38e1c09
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/ca.srl
@@ -0,0 +1 @@
+3232A5577A1F2B5B0AF818CC1E125BA8B9AA228F
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/generate-ca b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/generate-ca
new file mode 100755
index 000000000..8d63e6e27
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/generate-ca
@@ -0,0 +1,21 @@
+#!/bin/bash
+set -euo pipefail
+
+CA_KEY="ca.key"
+CA_CERT="ca.pem"
+
+if [[ -f "$CA_KEY" && -f "$CA_CERT" ]]; then
+ echo "✅ CA already exists: $CA_KEY, $CA_CERT"
+ exit 0
+fi
+
+echo "🔧 Generating root CA..."
+
+openssl genrsa -out "$CA_KEY" 4096
+
+openssl req -x509 -new -nodes -key "$CA_KEY" \
+ -sha256 -days 3650 \
+ -out "$CA_CERT" \
+ -subj "/CN=Local Dev CA"
+
+echo "✅ Root CA created: $CA_CERT"
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/generate-cert b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/generate-cert
new file mode 100755
index 000000000..808158f90
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/generate-cert
@@ -0,0 +1,52 @@
+#!/bin/bash
+set -euo pipefail
+
+CN="${1:-}"
+HOST="${2:-}"
+
+if [[ -z "$CN" || -z "$HOST" ]]; then
+ echo "Usage: $0 " >&2
+ exit 1
+fi
+
+# Set up working temp dir
+WORKDIR=$(mktemp -d)
+trap "rm -rf $WORKDIR" EXIT
+
+CA_KEY="ca.key"
+CA_CERT="ca.pem"
+
+# === Ensure CA exists ===
+if [[ ! -f $CA_KEY || ! -f $CA_CERT ]]; then
+ echo "🔧 Generating CA..."
+ openssl genrsa -out $CA_KEY 4096
+ openssl req -x509 -new -nodes -key $CA_KEY -sha256 -days 3650 -out $CA_CERT \
+ -subj "/CN=Local Dev CA"
+fi
+
+# === Generate key and CSR ===
+openssl genrsa -out "$WORKDIR/key.pem" 2048
+openssl req -new -key "$WORKDIR/key.pem" -out "$WORKDIR/cert.csr" \
+ -subj "/CN=$CN"
+
+cat > "$WORKDIR/cert.ext" < "$WORKDIR/chain.pem"
+cp "$CA_CERT" "$WORKDIR/ca.pem"
+echo "$CN" > "$WORKDIR/alias"
+
+# === Emit tarball to stdout ===
+tar -C "$WORKDIR" -cf - cert.pem key.pem chain.pem ca.pem alias
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/generate-stores b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/generate-stores
new file mode 100755
index 000000000..95bbcb59c
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/generate-stores
@@ -0,0 +1,56 @@
+#!/bin/bash
+set -euo pipefail
+
+# Ensure CA exists
+generate-ca
+
+# Shared configuration
+PASSWORD="password"
+HOST="localhost"
+
+# App definitions: CN, keystore name, truststore name
+declare -A APPS=(
+ [api]="api"
+ [client]="josh"
+)
+
+# Ensure required scripts are on PATH
+for cmd in generate-cert add-to-keystore add-to-truststore; do
+ if ! command -v $cmd >/dev/null 2>&1; then
+ echo "❌ Required script '$cmd' not found in PATH" >&2
+ exit 1
+ fi
+done
+
+# Generate certs and populate keystores and truststores
+for APP in "${!APPS[@]}"; do
+ CN="${APPS[$APP]}"
+ KEYSTORE="${CN}-keystore.p12"
+
+ echo "🔐 Generating and installing cert for $APP ($CN)..."
+
+ # Generate cert and install in own keystore
+ generate-cert "$CN" "$APP.127.0.0.1.nip.io" | tee >(add-to-keystore "$KEYSTORE") > "${CN}-bundle.tar"
+
+done
+
+# Second pass: truststores — each app must trust all
+for RECEIVER in "${!APPS[@]}"; do
+ RECEIVER_CN="${APPS[$RECEIVER]}"
+ TRUSTSTORE="${RECEIVER_CN}-truststore.p12"
+
+ echo "🤝 Updating truststore for $RECEIVER..."
+
+ for ISSUER in "${!APPS[@]}"; do
+ ISSUER_CN="${APPS[$ISSUER]}"
+ BUNDLE="${ISSUER_CN}-bundle.tar"
+
+ echo " ↪ Trusting $ISSUER ($ISSUER_CN)"
+ cat "$BUNDLE" | add-to-truststore "$TRUSTSTORE"
+ done
+done
+
+# Cleanup bundles
+rm -f ./*-bundle.tar
+
+echo "✅ All keystores and truststores generated successfully."
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/josh-keystore.p12 b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/josh-keystore.p12
new file mode 100644
index 000000000..388432699
Binary files /dev/null and b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/josh-keystore.p12 differ
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/josh-truststore.p12 b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/josh-truststore.p12
new file mode 100644
index 000000000..01821e035
Binary files /dev/null and b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/etc/josh-truststore.p12 differ
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/gradle.properties b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/gradle.properties
new file mode 100644
index 000000000..a5a6444df
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/gradle.properties
@@ -0,0 +1,4 @@
+version=6.1.1
+spring-security.version=7.0.0-SNAPSHOT
+org.gradle.jvmargs=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError
+org.gradle.caching=true
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/gradle/libs.versions.toml b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/gradle/libs.versions.toml
new file mode 120000
index 000000000..ebb52ed22
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/gradle/libs.versions.toml
@@ -0,0 +1 @@
+../../../../../../../gradle/libs.versions.toml
\ No newline at end of file
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/gradle/wrapper/gradle-wrapper.jar b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 000000000..249e5832f
Binary files /dev/null and b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/gradle/wrapper/gradle-wrapper.properties b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000..1e2fbf0d4
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/gradlew b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/gradlew
new file mode 100755
index 000000000..a69d9cb6c
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/gradlew
@@ -0,0 +1,240 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+
+APP_NAME="Gradle"
+APP_BASE_NAME=${0##*/}
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+# Collect all arguments for the java command;
+# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+# shell script including quotes and variable substitutions, so put them in
+# double quotes to make sure that they get re-expanded; and
+# * put everything else in single quotes, so that it's not re-expanded.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/gradlew.bat b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/gradlew.bat
new file mode 100644
index 000000000..53a6b238d
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/gradlew.bat
@@ -0,0 +1,91 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/settings.gradle b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/settings.gradle
new file mode 100644
index 000000000..733fda690
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/settings.gradle
@@ -0,0 +1,8 @@
+pluginManagement {
+ repositories {
+ mavenCentral()
+ gradlePluginPortal()
+ maven { url 'https://repo.spring.io/milestone' }
+ maven { url "https://repo.spring.io/snapshot" }
+ }
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/src/main/java/example/MfaApplication.java b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/src/main/java/example/MfaApplication.java
new file mode 100644
index 000000000..37b0fc765
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/src/main/java/example/MfaApplication.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2021 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.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package example;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * Hello Security application.
+ *
+ * @author Josh Cummings
+ */
+@SpringBootApplication
+public class MfaApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(MfaApplication.class, args);
+ }
+
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/src/main/java/example/SecurityConfig.java b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/src/main/java/example/SecurityConfig.java
new file mode 100644
index 000000000..5d6a44172
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/src/main/java/example/SecurityConfig.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2021 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.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package example;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.provisioning.InMemoryUserDetailsManager;
+import org.springframework.security.web.SecurityFilterChain;
+
+@Configuration
+public class SecurityConfig {
+
+ @Bean
+ SecurityFilterChain web(HttpSecurity http) throws Exception {
+ // @formatter:off
+ http
+ .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
+ .x509((x509) -> x509.factor(Customizer.withDefaults()))
+ .formLogin((form) -> form.factor(Customizer.withDefaults()));
+ // @formatter:on
+ return http.build();
+ }
+
+ @Bean
+ public UserDetailsService userDetailsService() {
+ return new InMemoryUserDetailsManager(
+ User.withDefaultPasswordEncoder()
+ .username("josh")
+ .password("password")
+ .authorities("app")
+ .build()
+ );
+ }
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/src/main/resources/api-keystore.p12 b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/src/main/resources/api-keystore.p12
new file mode 120000
index 000000000..07aa33fc9
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/src/main/resources/api-keystore.p12
@@ -0,0 +1 @@
+../../../etc/api-keystore.p12
\ No newline at end of file
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/src/main/resources/api-truststore.p12 b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/src/main/resources/api-truststore.p12
new file mode 120000
index 000000000..9d60902a6
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/src/main/resources/api-truststore.p12
@@ -0,0 +1 @@
+../../../etc/api-truststore.p12
\ No newline at end of file
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/src/main/resources/application.properties b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/src/main/resources/application.properties
new file mode 100644
index 000000000..c8b145c1b
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/src/main/resources/application.properties
@@ -0,0 +1,12 @@
+logging.level.org.springframework.security=TRACE
+
+server.port=8443
+server.ssl.enabled=true
+server.ssl.key-store-type=PKCS12
+server.ssl.key-store=classpath:api-keystore.p12
+server.ssl.key-store-password=password
+server.ssl.key-alias=api
+server.ssl.trust-store-type=PKCS12
+server.ssl.trust-store=classpath:api-truststore.p12
+server.ssl.trust-store-password=password
+server.ssl.client-auth=need
\ No newline at end of file
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/src/main/resources/templates/index.html b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/src/main/resources/templates/index.html
new file mode 100644
index 000000000..4e71378a5
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/src/main/resources/templates/index.html
@@ -0,0 +1,9 @@
+
+
+ Hello Security!
+
+
+ Hello Security
+ Log Out
+
+
\ No newline at end of file
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+formLogin/src/test/java/example/MfaApplicationTests.java b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/src/test/java/example/MfaApplicationTests.java
new file mode 100644
index 000000000..78f31ed0f
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+formLogin/src/test/java/example/MfaApplicationTests.java
@@ -0,0 +1,190 @@
+/*
+ * Copyright 2021 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.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package example;
+
+import com.j256.twofactorauth.TimeBasedOneTimePasswordUtil;
+import jakarta.servlet.http.HttpSession;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.mock.web.MockHttpSession;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
+
+/**
+ * @author Rob Winch
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@Disabled
+public class MfaApplicationTests {
+
+ private static final String hexKey = "80ed266dd80bcd32564f0f4aaa8d9b149a2b1eaa";
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Test
+ void mfaWhenAllFactorsSucceedMatchesThenWorks() throws Exception {
+ // @formatter:off
+ MvcResult result = this.mockMvc.perform(formLogin()
+ .user("user@example.com")
+ .password("password"))
+ .andExpect(redirectedUrl("/second-factor"))
+ .andReturn();
+
+ HttpSession session = result.getRequest().getSession();
+
+ Integer code = TimeBasedOneTimePasswordUtil.generateCurrentNumberHex(hexKey);
+ this.mockMvc.perform(post("/second-factor")
+ .session((MockHttpSession) session)
+ .param("code", String.valueOf(code))
+ .with(csrf()))
+ .andExpect(redirectedUrl("/third-factor"));
+
+ this.mockMvc.perform(post("/third-factor")
+ .session((MockHttpSession) session)
+ .param("answer", "smith")
+ .with(csrf()))
+ .andExpect(redirectedUrl("/"));
+ // @formatter:on
+ }
+
+ @Test
+ void mfaWhenBadCredsThenStillRequestsRemainingFactorsAndRedirects() throws Exception {
+ // @formatter:off
+ MvcResult result = this.mockMvc.perform(formLogin()
+ .user("user@example.com")
+ .password("wrongpassword"))
+ .andExpect(redirectedUrl("/second-factor"))
+ .andReturn();
+
+ HttpSession session = result.getRequest().getSession();
+
+ Integer code = TimeBasedOneTimePasswordUtil.generateCurrentNumberHex(hexKey);
+ this.mockMvc.perform(post("/second-factor")
+ .session((MockHttpSession) session)
+ .param("code", String.valueOf(code))
+ .with(csrf()))
+ .andExpect(redirectedUrl("/third-factor"));
+
+ this.mockMvc.perform(post("/third-factor")
+ .session((MockHttpSession) session)
+ .param("answer", "smith")
+ .with(csrf()))
+ .andExpect(redirectedUrl("/login?error"));
+ // @formatter:on
+ }
+
+ @Test
+ void mfaWhenWrongCodeThenRedirects() throws Exception {
+ // @formatter:off
+ MvcResult result = this.mockMvc.perform(formLogin()
+ .user("user@example.com")
+ .password("password"))
+ .andExpect(redirectedUrl("/second-factor"))
+ .andReturn();
+
+ HttpSession session = result.getRequest().getSession();
+
+ Integer code = TimeBasedOneTimePasswordUtil.generateCurrentNumberHex(hexKey) - 1;
+ this.mockMvc.perform(post("/second-factor")
+ .session((MockHttpSession) session)
+ .param("code", String.valueOf(code))
+ .with(csrf()))
+ .andExpect(redirectedUrl("/third-factor"));
+
+ this.mockMvc.perform(post("/third-factor")
+ .session((MockHttpSession) session)
+ .param("answer", "smith")
+ .with(csrf()))
+ .andExpect(redirectedUrl("/login?error"));
+ // @formatter:on
+ }
+
+ @Test
+ void mfaWhenWrongSecurityAnswerThenRedirects() throws Exception {
+ // @formatter:off
+ MvcResult result = this.mockMvc.perform(formLogin()
+ .user("user@example.com")
+ .password("password"))
+ .andExpect(redirectedUrl("/second-factor"))
+ .andReturn();
+
+ HttpSession session = result.getRequest().getSession();
+
+ Integer code = TimeBasedOneTimePasswordUtil.generateCurrentNumberHex(hexKey);
+ this.mockMvc.perform(post("/second-factor")
+ .session((MockHttpSession) session)
+ .param("code", String.valueOf(code))
+ .with(csrf()))
+ .andExpect(redirectedUrl("/third-factor"));
+
+ this.mockMvc.perform(post("/third-factor")
+ .session((MockHttpSession) session)
+ .param("answer", "wilson")
+ .with(csrf()))
+ .andExpect(redirectedUrl("/login?error"));
+ // @formatter:on
+ }
+
+ @Test
+ void mfaWhenInProcessThenCantViewOtherPages() throws Exception {
+ // @formatter:off
+ MvcResult result = this.mockMvc.perform(formLogin()
+ .user("user@example.com")
+ .password("password"))
+ .andExpect(redirectedUrl("/second-factor"))
+ .andReturn();
+
+ HttpSession session = result.getRequest().getSession();
+
+ this.mockMvc.perform(get("/")
+ .session((MockHttpSession) session))
+ .andExpect(redirectedUrl("http://localhost/login"));
+
+ result = this.mockMvc.perform(formLogin()
+ .user("user@example.com")
+ .password("password"))
+ .andExpect(redirectedUrl("/second-factor"))
+ .andReturn();
+
+ session = result.getRequest().getSession();
+
+ Integer code = TimeBasedOneTimePasswordUtil.generateCurrentNumberHex(hexKey);
+ this.mockMvc.perform(post("/second-factor")
+ .session((MockHttpSession) session)
+ .param("code", String.valueOf(code))
+ .with(csrf()))
+ .andExpect(redirectedUrl("/third-factor"));
+
+ this.mockMvc.perform(get("/")
+ .session((MockHttpSession) session))
+ .andExpect(redirectedUrl("http://localhost/login"));
+ // @formatter:on
+ }
+
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/README.adoc b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/README.adoc
new file mode 100644
index 000000000..48ea9fafb
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/README.adoc
@@ -0,0 +1,64 @@
+= X.509 + Form Login MFA Sample
+
+This sample demonstrates configuring Spring Security to require both an X.509 Certificate and a Username/Password Login in order to enter the site with full permissions.
+
+== Preparing to Use X.509
+
+This sample is intended to be used in a browser.
+As such, you should:
+
+1. Configure your browser to trust the `ca.crt` that accompanies this project
+2. Configure your browser with the `josh-keystore.p12` client certificate
+
+Both `api-keystore.p12` and `josh-keystore.p12` use keys signed by `ca.crt`.
+This means that after the above steps are performed, you can also use this application without getting a security warning in your browser.
+
+== Using the Sample
+
+To run, please use:
+
+.Java
+[source,java,role="primary"]
+----
+./gradlew :bootRun
+----
+
+This will start an application on 8443, meaning you will need to reach it using HTTPS.
+
+You can register a passkey at https://api.127.0.0.1.nip.io:8443/webauthn/register.
+
+With the client certificate (`josh-keystore.p12`) correctly installed in the browser, it will ask you which client certificate you want to you.
+Select `josh`.
+
+You will then be redirected to the PassKeys registration page where you can install a passkey.
+
+After that, navigate to https://api.127.0.0.1.nip.io:8443 and you will be redirected to page where you can provide a passkey.
+
+== Exploring the Sample
+
+The key configuration is found in the `HttpSecurity` DSL:
+
+.Java
+[source,java,role="primary"]
+----
+http
+ .x509(Customizer.withDefaults())
+ .webAuthn((webauthn) -> webauthn
+ // ...
+ .factor((f) -> f.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/webauthn")))
+ );
+----
+
+This reads, "This app requires both X.509 and WebAuthn to fully authorize; redirect to /webauthn to get the WebAuthn authority".
+
+You can instead try another arrangement like the following:
+
+.Java
+[source,java,role="primary"]
+----
+http
+ .x509(Customizer.withDefaults())
+ .oneTimeTokenLogin(Customizer.withDefaults())
+----
+
+Once `oneTimeTokenLogin` is correctly configured and once a client certificate is accepted, the application will generate a token and send it to the configured destination to continue with the login process.
\ No newline at end of file
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/build.gradle b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/build.gradle
new file mode 100644
index 000000000..ced211cef
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/build.gradle
@@ -0,0 +1,32 @@
+plugins {
+ alias(libs.plugins.io.spring.dependency.management)
+ alias(libs.plugins.org.springframework.boot)
+ id "nebula.integtest" version "8.2.0"
+ id 'java'
+}
+
+repositories {
+ mavenLocal()
+ mavenCentral()
+ maven { url "https://repo.spring.io/milestone" }
+ maven { url "https://repo.spring.io/snapshot" }
+}
+
+
+dependencies {
+ implementation 'org.springframework.boot:spring-boot-starter-security'
+ implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.security:spring-security-webauthn'
+
+
+ implementation "com.webauthn4j:webauthn4j-core:0.29.4.RELEASE"
+
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'org.springframework.security:spring-security-test'
+}
+
+tasks.withType(Test).configureEach {
+ useJUnitPlatform()
+
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/add-to-keystore b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/add-to-keystore
new file mode 100755
index 000000000..cb133de05
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/add-to-keystore
@@ -0,0 +1,44 @@
+#!/bin/bash
+set -euo pipefail
+
+KEYSTORE="${1:-}"
+if [[ -z "$KEYSTORE" ]]; then
+ echo "Usage: $0 " >&2
+ exit 1
+fi
+
+PASSWORD="password"
+
+# Set up temp workspace
+WORKDIR=$(mktemp -d)
+trap "rm -rf $WORKDIR" EXIT
+
+# Read input tar archive from stdin
+tar -C "$WORKDIR" -xf -
+
+ALIAS=$(cat "$WORKDIR/alias")
+CERT="$WORKDIR/cert.pem"
+KEY="$WORKDIR/key.pem"
+CHAIN="$WORKDIR/chain.pem"
+
+# Convert to PKCS#12 bundle
+PKCS12="$WORKDIR/temp.p12"
+openssl pkcs12 -export \
+ -inkey "$KEY" \
+ -in "$CERT" \
+ -certfile "$CHAIN" \
+ -name "$ALIAS" \
+ -out "$PKCS12" \
+ -passout pass:$PASSWORD
+
+# If alias exists, delete it
+if [[ -f "$KEYSTORE" ]]; then
+ keytool -delete -alias "$ALIAS" -keystore "$KEYSTORE" \
+ -storepass "$PASSWORD" -storetype PKCS12 || true
+fi
+
+# Import new entry
+keytool -importkeystore \
+ -destkeystore "$KEYSTORE" -deststoretype PKCS12 -deststorepass "$PASSWORD" \
+ -srckeystore "$PKCS12" -srcstoretype PKCS12 -srcstorepass "$PASSWORD" \
+ -alias "$ALIAS"
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/add-to-truststore b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/add-to-truststore
new file mode 100755
index 000000000..0f7393074
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/add-to-truststore
@@ -0,0 +1,38 @@
+#!/bin/bash
+set -euo pipefail
+
+TRUSTSTORE="${1:-}"
+if [[ -z "$TRUSTSTORE" ]]; then
+ echo "Usage: $0 " >&2
+ exit 1
+fi
+
+PASSWORD="password"
+
+# Temp workspace
+WORKDIR=$(mktemp -d)
+trap "rm -rf $WORKDIR" EXIT
+
+# Extract from tar input
+tar -C "$WORKDIR" -xf -
+
+ALIAS=$(cat "$WORKDIR/alias")
+CA_CERT="$WORKDIR/ca.pem"
+DER_CERT="$WORKDIR/ca.der"
+
+# Convert to DER format for keytool
+openssl x509 -in "$CA_CERT" -outform DER -out "$DER_CERT"
+
+# If alias exists, delete
+if [[ -f "$TRUSTSTORE" ]]; then
+ keytool -delete -alias "$ALIAS" -keystore "$TRUSTSTORE" \
+ -storepass "$PASSWORD" -storetype PKCS12 || true
+fi
+
+# Import into truststore
+keytool -importcert -noprompt \
+ -alias "$ALIAS" \
+ -file "$DER_CERT" \
+ -keystore "$TRUSTSTORE" \
+ -storetype PKCS12 \
+ -storepass "$PASSWORD"
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/api-keystore.p12 b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/api-keystore.p12
new file mode 100644
index 000000000..b36b72752
Binary files /dev/null and b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/api-keystore.p12 differ
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/api-truststore.p12 b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/api-truststore.p12
new file mode 100644
index 000000000..56281f545
Binary files /dev/null and b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/api-truststore.p12 differ
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/ca.crt b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/ca.crt
new file mode 100644
index 000000000..4c893d45d
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/ca.crt
@@ -0,0 +1,30 @@
+-----BEGIN CERTIFICATE-----
+MIIFDzCCAvegAwIBAgIUHzvf6/d0vmFFEYh5GHA/I8t787YwDQYJKoZIhvcNAQEL
+BQAwFzEVMBMGA1UEAwwMTG9jYWwgRGV2IENBMB4XDTI1MDcxMjE4MDM0NFoXDTM1
+MDcxMDE4MDM0NFowFzEVMBMGA1UEAwwMTG9jYWwgRGV2IENBMIICIjANBgkqhkiG
+9w0BAQEFAAOCAg8AMIICCgKCAgEAxezzB+gvNL5WeuzVvvlv21OvbbJtdP6k08Yz
+egIddIckQ2Yguuc5gHoUnNve5BVEQZmFNQihqLlzV0lFb+4QjqiH5BWKql+U7TRG
+GCLecF4VQHup5voQv6EUnVOJglc13NAjWqbP09M5aIxPNzfRv3eeZq2jLXu3aQ6Q
++mFq/cIGSlHF6KjqTTXoK1sRngZUeZi1/fhg1/9usH4L5nQ41y3D+51c5Zl+UW3w
+7bR70XkH3aX6X4xOOfZBiyp3wxrG7uQBddQAk2zu/hMTYWNAYswT15jIEk4JLH7o
+HXrVaoM5vnL0D5SaIc66JnAhwyC3E1jc7OpioFCO0Xi5XZAt3s+E0AwOpAvSomIH
+FdryfEHcbhqSUgUVof/c9PmLwtS2fVPm3LqqLjmwagO9DPDCGroNwoOKitfoH3qQ
+EtETygsKThMYLc23tLbyYsjPs5H8ro+s41aqcsCiQIhELRXwZVyCWj53o8tE4Ihh
+hdTzbzNmrBdv0H9vb3VS9SdfHYFJ409qIu+kkmsAQ8vd6e92UJXbKd/Vchmr2ogs
+7oGLIOID9KZMqe4dKKzU+ietLz0IVTWRaPz3ea7NGRIo3/yxGDMRF/Z0UwH53H+E
+crn8w/aMWeIqMq5h6sC24D9RkcVN+E5QzxvG2VS4A7CzskIrduAWl5TrBERChbiX
+wQusjYcCAwEAAaNTMFEwHQYDVR0OBBYEFLq0yPS9u7Flbh061DkXX79lvNltMB8G
+A1UdIwQYMBaAFLq0yPS9u7Flbh061DkXX79lvNltMA8GA1UdEwEB/wQFMAMBAf8w
+DQYJKoZIhvcNAQELBQADggIBABMh+cLO2ge0FKNkJriiBXs3ah6w/GGWVgFK6BmU
+297fM9FSV/1bQcTUe1gpudabGRsq7TthC/aK3B+79tsfudcrPKJZrK7cFkxY06xT
+3el45RQxZNYvaHRsK7nw6gExCLlYFKAatHcdvbk6xe+XZAAr4rLYg1H1n7Ddg3p/
+K3l0a/6nAyMND7T1euzijR+40EZdiXzwsgFR3R2YIWiNmLu/z3tg0HfYIgrQaOou
+Xl06qXZ1iJW1EFuF/KoxNBxyJQpHywYTvb6UuGV2UOvI3V7SzzgyBvBxOUf4djNM
+kEK1+U5lnMqSDgHWkEzNUFMlsIwIiEWSMo+deF+/9FiAQxUX85kH9O8/7t9rqzxK
+mgVccTnzvEcYyRYgL1SsCXo1oJfZ8M4OnZ/dDY0y86kngrgkIxFpPPAh0VVpyVGn
+A9CyYU7vQxTrgwHtqITMN7kNBWEAZgA82kURbZm/DDoKX5IJdcMdwvFaVk2irSyQ
+x0Px+k5eNhSGdKctXESAAUeUADpaYhRZeRI7jJDPCJGvs5or4hiA/3O8ZI5Lum12
+fKNSau4m3jbTWrD4x7zlbVES9hRB87f/uST0/yh4NfdjrspzNINWXArxPDsYs7Sf
+HDVj3RPNKVGzJEH4+J+Ohti3JTEl8hLBO3ZoMJ2uPQ44wcJAcKVL7O3QraKOfzXu
+k1d0
+-----END CERTIFICATE-----
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/ca.key b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/ca.key
new file mode 100644
index 000000000..653f8d1c1
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/ca.key
@@ -0,0 +1,52 @@
+-----BEGIN PRIVATE KEY-----
+MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQDF7PMH6C80vlZ6
+7NW++W/bU69tsm10/qTTxjN6Ah10hyRDZiC65zmAehSc297kFURBmYU1CKGouXNX
+SUVv7hCOqIfkFYqqX5TtNEYYIt5wXhVAe6nm+hC/oRSdU4mCVzXc0CNaps/T0zlo
+jE83N9G/d55mraMte7dpDpD6YWr9wgZKUcXoqOpNNegrWxGeBlR5mLX9+GDX/26w
+fgvmdDjXLcP7nVzlmX5RbfDttHvReQfdpfpfjE459kGLKnfDGsbu5AF11ACTbO7+
+ExNhY0BizBPXmMgSTgksfugdetVqgzm+cvQPlJohzromcCHDILcTWNzs6mKgUI7R
+eLldkC3ez4TQDA6kC9KiYgcV2vJ8QdxuGpJSBRWh/9z0+YvC1LZ9U+bcuqouObBq
+A70M8MIaug3Cg4qK1+gfepAS0RPKCwpOExgtzbe0tvJiyM+zkfyuj6zjVqpywKJA
+iEQtFfBlXIJaPnejy0TgiGGF1PNvM2asF2/Qf29vdVL1J18dgUnjT2oi76SSawBD
+y93p73ZQldsp39VyGavaiCzugYsg4gP0pkyp7h0orNT6J60vPQhVNZFo/Pd5rs0Z
+Eijf/LEYMxEX9nRTAfncf4RyufzD9oxZ4ioyrmHqwLbgP1GRxU34TlDPG8bZVLgD
+sLOyQit24BaXlOsEREKFuJfBC6yNhwIDAQABAoICAAkuOFFZt0f44C7ioIpYLG/r
+hMu9XbPNr/NCLdcTp3eJzuhej1eFLCShTiaaLM+H1ZH00XDMu9ZOYqw5X7Anj3Hn
+r5cWEUfLPJ1Te0DZWcVqh8RlOAq0QByhF676w7EUdaNFZNL85hhdWjvpW+Yj7WW2
+T/rBRIVgSB3BzeyWD+lmLdHxiexgEWYiAURys181gxlLpjRDHzv8edhT50LVZPPX
+UXTeeHEOfynjeuL7uk1m7Vd5m9mTllS8gY83YuwmjM/0vOm+qKnkyg9mE2Z8Bjbz
+hKQYvIcAQsYaBEXvf1wgtBpCX5rbHmzT4Kf66qqP8Fcf0xL4c6b3I1QtdTXwg63N
+nKB7IeZTeVI2k0UF4Rme4tDl9LzlhiR7SRMd76e+8684GmUAXsF7TEdXIPAlQV9y
+GsF+dit16nMZPMIhWT+M1MOV94eVLu2ncEYP1dR4bZeFprxmv6DxgtxczqVyJQ9c
+4t2ujnekssxLHnkpOrV4CbkAuD3TUJsEs3359HmjGLS+VnGJrbvjILMbqgvN5hB5
+A37Rfals4f+O0MVAyV8sToqcTv2tGORe3aIl1JeExElTMl8ij+a9sRWwUDic1xTI
+F6YsRdyWLrCpwe00mlmb+ZlHqDObFbTHTKpUQoqxKUxn3uxsxPK6sLFjLg3sCa6X
+kuUS88uZwk76l3pGwltpAoIBAQDnNx80QvApz5M4/jp6vE6sAgyyDGwoqX1850ly
+tzCiCbKC0/9uVF2wpcRScjZOjIkdQ6ROKTDZiC9fFMbdbORhCjKa1fG9OKCIr2uQ
+KKgyhtjBSyzCMpFX7djbZrkSNVAGsvlTNrg2ANUoy9amm5rJoXJfuhDwdgc50uey
+NHU6CmAWIaBUHlpEtV9ABURLh8Ipj+4QiK4ufsOJgUWNWwoaSL73x4HKVPrG7jc0
+EJ/U9oIGvSjE0Q41u6y+9LODSteN4gkp4dcHWpEJnk/xUWOojT/kbKjKfzKLTajA
++1KmLR1piAH59gx3C6zwRllFJBoB9a030cdIjU9a0XypSu+lAoIBAQDbJE9CNfRr
+es1vAA9ukNvpF6WLg8TTr1VR5mSweb4SGIlKqe+5kd87c73EhJYiE0tr7ZsqHKCF
+p6OqvhV65ICl6214Hd4qV+J5rPxWUTEs7SWxdl2JVmCGZpn3nFWFwF7DQfJYihX3
+D9HyMpzJg2HLZRv29WFrhQ4JdTdPi31/UW17ECUkqal9Z7XZSVvWR3SfbUImrI9x
+eX7SqKusindv974obJFEo01UmFITwVtInSf1T+Xc3twvqOSZD7DnABJx68vUwxyo
+EZlPKKT1fCNXlFUKQoPmG+y742IThAPiN89UfDahbecJ+HZUrfrK8UUDcVV5KkBH
+9CSFb+kHGYC7AoIBAG46rTmxH+YO+9UT/rU8yRTf9UV8/qN0CktdyHpUM29MyDnu
+77udpPzuSmYz5QgVn9i/wrkwkgVjE5J0yUoO++H3hqCilpjrQj1nxBP6DhXoi7W7
+LR94FCqjTdtrYZf4qqpG8O5nC/NS+kx0wWS0klrGCUzx29mHq3I5xhQDRk/hWmWy
+qkjwH4DaJwrSd/i6RCqkX46qWr/31yja5Fm7qVlWjRR7nLjlQplMQC0mL8zLqLml
+vKX4NJoRWw2+g0Z4i8Msm8nHzUfIOZUoUFxvvN9CV8+CrgW8FlCrOWSnbIOkxnzl
+RmvwjYjDnDMAltaLm4qLoYUXEbbZB5f4f0IGY7ECggEARS38O2mvBHMbAUyikoP2
+eGo3n4h0jWMPazBxXui/4RSP2ts0y39KWolaQfydLJqst6Cl2DB7WFYoq9EgFNCn
+8DkXMNE0/mcKHuFGM7Wj8YvX12MHekCjbipbtrhKo1OsVrWt3NeSwZDj9TKXHmJ0
+b/I2Vsr1+yxg1wmC8YCWmKfLCQt6vk01LVqdJMAs1sNuBJpIRM865Va2e6g1sd1w
+gQ9Tn41Oer2WvvrrBkOHHrBGGgIkDYrpNb56k/tJHFOAfygyC7Ogi0oq/LtXAAw1
+WAOCqR+AZhcwr8vDfWeyliqKMCCaWnHIevRN3sOhpYlvAPw5QGvfKRfgo6NFjDE3
+2wKCAQEAkd3F5feBAPiGr354kyGmKGSUC1KKmaNCQCthPxt6Wb0QRHBa4yj6sHf5
+YcoL0WLFEIftKFDsbCMPLSbLE2SvsWFQmHu7L4B7xdd+0jcAcM5eOBcTqSptCc/v
+uVMRANhYlgXQqtPHp1q2uBkC2++aE/yuGmrngyIkwP9ewyzLmsZBSpIrGB//qav6
+DzRATKwF9YI6+v67CFkBLSQ5JES07FWxQSp3aMHCiuGsKfOk+wj2yhMi7KMHZA82
+0fJ7c7heUedFu3+Gt5nJR/bdgC7Tva9iDo/UC7FTmMF0ZU18fXAtzDh2pYoTm3o3
+UZaWgz2MB+hNvTCg+8toreA0o4/SiA==
+-----END PRIVATE KEY-----
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/ca.pem b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/ca.pem
new file mode 100644
index 000000000..4c893d45d
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/ca.pem
@@ -0,0 +1,30 @@
+-----BEGIN CERTIFICATE-----
+MIIFDzCCAvegAwIBAgIUHzvf6/d0vmFFEYh5GHA/I8t787YwDQYJKoZIhvcNAQEL
+BQAwFzEVMBMGA1UEAwwMTG9jYWwgRGV2IENBMB4XDTI1MDcxMjE4MDM0NFoXDTM1
+MDcxMDE4MDM0NFowFzEVMBMGA1UEAwwMTG9jYWwgRGV2IENBMIICIjANBgkqhkiG
+9w0BAQEFAAOCAg8AMIICCgKCAgEAxezzB+gvNL5WeuzVvvlv21OvbbJtdP6k08Yz
+egIddIckQ2Yguuc5gHoUnNve5BVEQZmFNQihqLlzV0lFb+4QjqiH5BWKql+U7TRG
+GCLecF4VQHup5voQv6EUnVOJglc13NAjWqbP09M5aIxPNzfRv3eeZq2jLXu3aQ6Q
++mFq/cIGSlHF6KjqTTXoK1sRngZUeZi1/fhg1/9usH4L5nQ41y3D+51c5Zl+UW3w
+7bR70XkH3aX6X4xOOfZBiyp3wxrG7uQBddQAk2zu/hMTYWNAYswT15jIEk4JLH7o
+HXrVaoM5vnL0D5SaIc66JnAhwyC3E1jc7OpioFCO0Xi5XZAt3s+E0AwOpAvSomIH
+FdryfEHcbhqSUgUVof/c9PmLwtS2fVPm3LqqLjmwagO9DPDCGroNwoOKitfoH3qQ
+EtETygsKThMYLc23tLbyYsjPs5H8ro+s41aqcsCiQIhELRXwZVyCWj53o8tE4Ihh
+hdTzbzNmrBdv0H9vb3VS9SdfHYFJ409qIu+kkmsAQ8vd6e92UJXbKd/Vchmr2ogs
+7oGLIOID9KZMqe4dKKzU+ietLz0IVTWRaPz3ea7NGRIo3/yxGDMRF/Z0UwH53H+E
+crn8w/aMWeIqMq5h6sC24D9RkcVN+E5QzxvG2VS4A7CzskIrduAWl5TrBERChbiX
+wQusjYcCAwEAAaNTMFEwHQYDVR0OBBYEFLq0yPS9u7Flbh061DkXX79lvNltMB8G
+A1UdIwQYMBaAFLq0yPS9u7Flbh061DkXX79lvNltMA8GA1UdEwEB/wQFMAMBAf8w
+DQYJKoZIhvcNAQELBQADggIBABMh+cLO2ge0FKNkJriiBXs3ah6w/GGWVgFK6BmU
+297fM9FSV/1bQcTUe1gpudabGRsq7TthC/aK3B+79tsfudcrPKJZrK7cFkxY06xT
+3el45RQxZNYvaHRsK7nw6gExCLlYFKAatHcdvbk6xe+XZAAr4rLYg1H1n7Ddg3p/
+K3l0a/6nAyMND7T1euzijR+40EZdiXzwsgFR3R2YIWiNmLu/z3tg0HfYIgrQaOou
+Xl06qXZ1iJW1EFuF/KoxNBxyJQpHywYTvb6UuGV2UOvI3V7SzzgyBvBxOUf4djNM
+kEK1+U5lnMqSDgHWkEzNUFMlsIwIiEWSMo+deF+/9FiAQxUX85kH9O8/7t9rqzxK
+mgVccTnzvEcYyRYgL1SsCXo1oJfZ8M4OnZ/dDY0y86kngrgkIxFpPPAh0VVpyVGn
+A9CyYU7vQxTrgwHtqITMN7kNBWEAZgA82kURbZm/DDoKX5IJdcMdwvFaVk2irSyQ
+x0Px+k5eNhSGdKctXESAAUeUADpaYhRZeRI7jJDPCJGvs5or4hiA/3O8ZI5Lum12
+fKNSau4m3jbTWrD4x7zlbVES9hRB87f/uST0/yh4NfdjrspzNINWXArxPDsYs7Sf
+HDVj3RPNKVGzJEH4+J+Ohti3JTEl8hLBO3ZoMJ2uPQ44wcJAcKVL7O3QraKOfzXu
+k1d0
+-----END CERTIFICATE-----
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/ca.srl b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/ca.srl
new file mode 100644
index 000000000..8b38e1c09
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/ca.srl
@@ -0,0 +1 @@
+3232A5577A1F2B5B0AF818CC1E125BA8B9AA228F
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/generate-ca b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/generate-ca
new file mode 100755
index 000000000..8d63e6e27
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/generate-ca
@@ -0,0 +1,21 @@
+#!/bin/bash
+set -euo pipefail
+
+CA_KEY="ca.key"
+CA_CERT="ca.pem"
+
+if [[ -f "$CA_KEY" && -f "$CA_CERT" ]]; then
+ echo "✅ CA already exists: $CA_KEY, $CA_CERT"
+ exit 0
+fi
+
+echo "🔧 Generating root CA..."
+
+openssl genrsa -out "$CA_KEY" 4096
+
+openssl req -x509 -new -nodes -key "$CA_KEY" \
+ -sha256 -days 3650 \
+ -out "$CA_CERT" \
+ -subj "/CN=Local Dev CA"
+
+echo "✅ Root CA created: $CA_CERT"
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/generate-cert b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/generate-cert
new file mode 100755
index 000000000..808158f90
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/generate-cert
@@ -0,0 +1,52 @@
+#!/bin/bash
+set -euo pipefail
+
+CN="${1:-}"
+HOST="${2:-}"
+
+if [[ -z "$CN" || -z "$HOST" ]]; then
+ echo "Usage: $0 " >&2
+ exit 1
+fi
+
+# Set up working temp dir
+WORKDIR=$(mktemp -d)
+trap "rm -rf $WORKDIR" EXIT
+
+CA_KEY="ca.key"
+CA_CERT="ca.pem"
+
+# === Ensure CA exists ===
+if [[ ! -f $CA_KEY || ! -f $CA_CERT ]]; then
+ echo "🔧 Generating CA..."
+ openssl genrsa -out $CA_KEY 4096
+ openssl req -x509 -new -nodes -key $CA_KEY -sha256 -days 3650 -out $CA_CERT \
+ -subj "/CN=Local Dev CA"
+fi
+
+# === Generate key and CSR ===
+openssl genrsa -out "$WORKDIR/key.pem" 2048
+openssl req -new -key "$WORKDIR/key.pem" -out "$WORKDIR/cert.csr" \
+ -subj "/CN=$CN"
+
+cat > "$WORKDIR/cert.ext" < "$WORKDIR/chain.pem"
+cp "$CA_CERT" "$WORKDIR/ca.pem"
+echo "$CN" > "$WORKDIR/alias"
+
+# === Emit tarball to stdout ===
+tar -C "$WORKDIR" -cf - cert.pem key.pem chain.pem ca.pem alias
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/generate-stores b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/generate-stores
new file mode 100755
index 000000000..95bbcb59c
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/generate-stores
@@ -0,0 +1,56 @@
+#!/bin/bash
+set -euo pipefail
+
+# Ensure CA exists
+generate-ca
+
+# Shared configuration
+PASSWORD="password"
+HOST="localhost"
+
+# App definitions: CN, keystore name, truststore name
+declare -A APPS=(
+ [api]="api"
+ [client]="josh"
+)
+
+# Ensure required scripts are on PATH
+for cmd in generate-cert add-to-keystore add-to-truststore; do
+ if ! command -v $cmd >/dev/null 2>&1; then
+ echo "❌ Required script '$cmd' not found in PATH" >&2
+ exit 1
+ fi
+done
+
+# Generate certs and populate keystores and truststores
+for APP in "${!APPS[@]}"; do
+ CN="${APPS[$APP]}"
+ KEYSTORE="${CN}-keystore.p12"
+
+ echo "🔐 Generating and installing cert for $APP ($CN)..."
+
+ # Generate cert and install in own keystore
+ generate-cert "$CN" "$APP.127.0.0.1.nip.io" | tee >(add-to-keystore "$KEYSTORE") > "${CN}-bundle.tar"
+
+done
+
+# Second pass: truststores — each app must trust all
+for RECEIVER in "${!APPS[@]}"; do
+ RECEIVER_CN="${APPS[$RECEIVER]}"
+ TRUSTSTORE="${RECEIVER_CN}-truststore.p12"
+
+ echo "🤝 Updating truststore for $RECEIVER..."
+
+ for ISSUER in "${!APPS[@]}"; do
+ ISSUER_CN="${APPS[$ISSUER]}"
+ BUNDLE="${ISSUER_CN}-bundle.tar"
+
+ echo " ↪ Trusting $ISSUER ($ISSUER_CN)"
+ cat "$BUNDLE" | add-to-truststore "$TRUSTSTORE"
+ done
+done
+
+# Cleanup bundles
+rm -f ./*-bundle.tar
+
+echo "✅ All keystores and truststores generated successfully."
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/josh-keystore.p12 b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/josh-keystore.p12
new file mode 100644
index 000000000..388432699
Binary files /dev/null and b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/josh-keystore.p12 differ
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/josh-truststore.p12 b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/josh-truststore.p12
new file mode 100644
index 000000000..01821e035
Binary files /dev/null and b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/etc/josh-truststore.p12 differ
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/gradle.properties b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/gradle.properties
new file mode 100644
index 000000000..a5a6444df
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/gradle.properties
@@ -0,0 +1,4 @@
+version=6.1.1
+spring-security.version=7.0.0-SNAPSHOT
+org.gradle.jvmargs=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError
+org.gradle.caching=true
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/gradle/libs.versions.toml b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/gradle/libs.versions.toml
new file mode 120000
index 000000000..ebb52ed22
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/gradle/libs.versions.toml
@@ -0,0 +1 @@
+../../../../../../../gradle/libs.versions.toml
\ No newline at end of file
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/gradle/wrapper/gradle-wrapper.jar b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 000000000..249e5832f
Binary files /dev/null and b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/gradle/wrapper/gradle-wrapper.properties b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000..1e2fbf0d4
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/gradlew b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/gradlew
new file mode 100755
index 000000000..a69d9cb6c
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/gradlew
@@ -0,0 +1,240 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+
+APP_NAME="Gradle"
+APP_BASE_NAME=${0##*/}
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+# Collect all arguments for the java command;
+# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+# shell script including quotes and variable substitutions, so put them in
+# double quotes to make sure that they get re-expanded; and
+# * put everything else in single quotes, so that it's not re-expanded.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/gradlew.bat b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/gradlew.bat
new file mode 100644
index 000000000..53a6b238d
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/gradlew.bat
@@ -0,0 +1,91 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/settings.gradle b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/settings.gradle
new file mode 100644
index 000000000..733fda690
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/settings.gradle
@@ -0,0 +1,8 @@
+pluginManagement {
+ repositories {
+ mavenCentral()
+ gradlePluginPortal()
+ maven { url 'https://repo.spring.io/milestone' }
+ maven { url "https://repo.spring.io/snapshot" }
+ }
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/main/java/example/SecurityConfig.java b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/main/java/example/SecurityConfig.java
new file mode 100644
index 000000000..0dda07bf7
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/main/java/example/SecurityConfig.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2021 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.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package example;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.provisioning.InMemoryUserDetailsManager;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+
+@Configuration
+public class SecurityConfig {
+
+ @Controller
+ static class LoginController {
+ @GetMapping("/webauthn")
+ String webauthn() {
+ return "webauthn";
+ }
+ }
+
+ @Bean
+ SecurityFilterChain web(HttpSecurity http) throws Exception {
+ // @formatter:off
+ http
+ .authorizeHttpRequests((authorize) -> authorize
+ .requestMatchers("/webauthn/**").permitAll()
+ .anyRequest().authenticated())
+ .x509((x509) -> x509.factor(Customizer.withDefaults()))
+ .formLogin(Customizer.withDefaults())
+ .webAuthn((webauthn) -> webauthn
+ .rpId("api.127.0.0.1.nip.io")
+ .rpName("X.509+WebAuthn MFA Sample")
+ .allowedOrigins("https://api.127.0.0.1.nip.io:8443")
+ .factor((f) -> f.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/webauthn")))
+ );
+ // @formatter:on
+ return http.build();
+ }
+
+ @Bean
+ public UserDetailsService userDetailsService() {
+ return new InMemoryUserDetailsManager(
+ User.withDefaultPasswordEncoder()
+ .username("josh")
+ .password("password")
+ .authorities("app")
+ .build()
+ );
+ }
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/main/java/example/X509WebAuthnMfaApplication.java b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/main/java/example/X509WebAuthnMfaApplication.java
new file mode 100644
index 000000000..6c9c62ac8
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/main/java/example/X509WebAuthnMfaApplication.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2021 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.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package example;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * Hello Security application.
+ *
+ * @author Josh Cummings
+ */
+@SpringBootApplication
+public class X509WebAuthnMfaApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(X509WebAuthnMfaApplication.class, args);
+ }
+
+}
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/main/resources/api-keystore.p12 b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/main/resources/api-keystore.p12
new file mode 120000
index 000000000..07aa33fc9
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/main/resources/api-keystore.p12
@@ -0,0 +1 @@
+../../../etc/api-keystore.p12
\ No newline at end of file
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/main/resources/api-truststore.p12 b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/main/resources/api-truststore.p12
new file mode 120000
index 000000000..9d60902a6
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/main/resources/api-truststore.p12
@@ -0,0 +1 @@
+../../../etc/api-truststore.p12
\ No newline at end of file
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/main/resources/application.properties b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/main/resources/application.properties
new file mode 100644
index 000000000..c8b145c1b
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/main/resources/application.properties
@@ -0,0 +1,12 @@
+logging.level.org.springframework.security=TRACE
+
+server.port=8443
+server.ssl.enabled=true
+server.ssl.key-store-type=PKCS12
+server.ssl.key-store=classpath:api-keystore.p12
+server.ssl.key-store-password=password
+server.ssl.key-alias=api
+server.ssl.trust-store-type=PKCS12
+server.ssl.trust-store=classpath:api-truststore.p12
+server.ssl.trust-store-password=password
+server.ssl.client-auth=need
\ No newline at end of file
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/main/resources/templates/index.html b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/main/resources/templates/index.html
new file mode 100644
index 000000000..4e71378a5
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/main/resources/templates/index.html
@@ -0,0 +1,9 @@
+
+
+ Hello Security!
+
+
+ Hello Security
+ Log Out
+
+
\ No newline at end of file
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/main/resources/templates/webauthn.html b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/main/resources/templates/webauthn.html
new file mode 100644
index 000000000..be032ec96
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/main/resources/templates/webauthn.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+ Please sign in
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/test/java/example/X509WebAuthnMfaApplicationTests.java b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/test/java/example/X509WebAuthnMfaApplicationTests.java
new file mode 100644
index 000000000..213b0d608
--- /dev/null
+++ b/servlet/spring-boot/java/authentication/mfa/x509+webauthn/src/test/java/example/X509WebAuthnMfaApplicationTests.java
@@ -0,0 +1,190 @@
+/*
+ * Copyright 2021 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.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package example;
+
+import com.j256.twofactorauth.TimeBasedOneTimePasswordUtil;
+import jakarta.servlet.http.HttpSession;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.mock.web.MockHttpSession;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
+
+/**
+ * @author Rob Winch
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@Disabled
+public class X509WebAuthnMfaApplicationTests {
+
+ private static final String hexKey = "80ed266dd80bcd32564f0f4aaa8d9b149a2b1eaa";
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Test
+ void mfaWhenAllFactorsSucceedMatchesThenWorks() throws Exception {
+ // @formatter:off
+ MvcResult result = this.mockMvc.perform(formLogin()
+ .user("user@example.com")
+ .password("password"))
+ .andExpect(redirectedUrl("/second-factor"))
+ .andReturn();
+
+ HttpSession session = result.getRequest().getSession();
+
+ Integer code = TimeBasedOneTimePasswordUtil.generateCurrentNumberHex(hexKey);
+ this.mockMvc.perform(post("/second-factor")
+ .session((MockHttpSession) session)
+ .param("code", String.valueOf(code))
+ .with(csrf()))
+ .andExpect(redirectedUrl("/third-factor"));
+
+ this.mockMvc.perform(post("/third-factor")
+ .session((MockHttpSession) session)
+ .param("answer", "smith")
+ .with(csrf()))
+ .andExpect(redirectedUrl("/"));
+ // @formatter:on
+ }
+
+ @Test
+ void mfaWhenBadCredsThenStillRequestsRemainingFactorsAndRedirects() throws Exception {
+ // @formatter:off
+ MvcResult result = this.mockMvc.perform(formLogin()
+ .user("user@example.com")
+ .password("wrongpassword"))
+ .andExpect(redirectedUrl("/second-factor"))
+ .andReturn();
+
+ HttpSession session = result.getRequest().getSession();
+
+ Integer code = TimeBasedOneTimePasswordUtil.generateCurrentNumberHex(hexKey);
+ this.mockMvc.perform(post("/second-factor")
+ .session((MockHttpSession) session)
+ .param("code", String.valueOf(code))
+ .with(csrf()))
+ .andExpect(redirectedUrl("/third-factor"));
+
+ this.mockMvc.perform(post("/third-factor")
+ .session((MockHttpSession) session)
+ .param("answer", "smith")
+ .with(csrf()))
+ .andExpect(redirectedUrl("/login?error"));
+ // @formatter:on
+ }
+
+ @Test
+ void mfaWhenWrongCodeThenRedirects() throws Exception {
+ // @formatter:off
+ MvcResult result = this.mockMvc.perform(formLogin()
+ .user("user@example.com")
+ .password("password"))
+ .andExpect(redirectedUrl("/second-factor"))
+ .andReturn();
+
+ HttpSession session = result.getRequest().getSession();
+
+ Integer code = TimeBasedOneTimePasswordUtil.generateCurrentNumberHex(hexKey) - 1;
+ this.mockMvc.perform(post("/second-factor")
+ .session((MockHttpSession) session)
+ .param("code", String.valueOf(code))
+ .with(csrf()))
+ .andExpect(redirectedUrl("/third-factor"));
+
+ this.mockMvc.perform(post("/third-factor")
+ .session((MockHttpSession) session)
+ .param("answer", "smith")
+ .with(csrf()))
+ .andExpect(redirectedUrl("/login?error"));
+ // @formatter:on
+ }
+
+ @Test
+ void mfaWhenWrongSecurityAnswerThenRedirects() throws Exception {
+ // @formatter:off
+ MvcResult result = this.mockMvc.perform(formLogin()
+ .user("user@example.com")
+ .password("password"))
+ .andExpect(redirectedUrl("/second-factor"))
+ .andReturn();
+
+ HttpSession session = result.getRequest().getSession();
+
+ Integer code = TimeBasedOneTimePasswordUtil.generateCurrentNumberHex(hexKey);
+ this.mockMvc.perform(post("/second-factor")
+ .session((MockHttpSession) session)
+ .param("code", String.valueOf(code))
+ .with(csrf()))
+ .andExpect(redirectedUrl("/third-factor"));
+
+ this.mockMvc.perform(post("/third-factor")
+ .session((MockHttpSession) session)
+ .param("answer", "wilson")
+ .with(csrf()))
+ .andExpect(redirectedUrl("/login?error"));
+ // @formatter:on
+ }
+
+ @Test
+ void mfaWhenInProcessThenCantViewOtherPages() throws Exception {
+ // @formatter:off
+ MvcResult result = this.mockMvc.perform(formLogin()
+ .user("user@example.com")
+ .password("password"))
+ .andExpect(redirectedUrl("/second-factor"))
+ .andReturn();
+
+ HttpSession session = result.getRequest().getSession();
+
+ this.mockMvc.perform(get("/")
+ .session((MockHttpSession) session))
+ .andExpect(redirectedUrl("http://localhost/login"));
+
+ result = this.mockMvc.perform(formLogin()
+ .user("user@example.com")
+ .password("password"))
+ .andExpect(redirectedUrl("/second-factor"))
+ .andReturn();
+
+ session = result.getRequest().getSession();
+
+ Integer code = TimeBasedOneTimePasswordUtil.generateCurrentNumberHex(hexKey);
+ this.mockMvc.perform(post("/second-factor")
+ .session((MockHttpSession) session)
+ .param("code", String.valueOf(code))
+ .with(csrf()))
+ .andExpect(redirectedUrl("/third-factor"));
+
+ this.mockMvc.perform(get("/")
+ .session((MockHttpSession) session))
+ .andExpect(redirectedUrl("http://localhost/login"));
+ // @formatter:on
+ }
+
+}
diff --git a/settings.gradle b/settings.gradle
index 71191ba5d..1785cb8ba 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -52,6 +52,10 @@ include ":servlet:spring-boot:java:acl"
include ":servlet:spring-boot:java:aot:data"
include ":servlet:spring-boot:java:authentication:username-password:user-details-service:custom-user"
include ":servlet:spring-boot:java:authentication:username-password:mfa"
+include ":servlet:spring-boot:java:authentication:mfa:x509+formLogin"
+include ":servlet:spring-boot:java:authentication:mfa:x509+webauthn"
+include ":servlet:spring-boot:java:authentication:mfa:formLogin+ott"
+include ":servlet:spring-boot:java:authentication:mfa:oauth2"
include ":servlet:spring-boot:java:authentication:username-password:compromised-password-checker"
include ":servlet:spring-boot:java:authentication:one-time-token:magic-link"
include ":servlet:spring-boot:java:data"