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 + + + +
+
+

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 + + + +
+
+

Please supply a token

+ +

+ + +

+ + +
+
+ + \ 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"