diff --git a/README.adoc b/README.adoc index 471ff1a0a..3f4a9a21b 100644 --- a/README.adoc +++ b/README.adoc @@ -40,6 +40,8 @@ Samples for https://github.com/spring-projects/spring-security ** https://github.com/spring-projects/spring-security-samples/tree/main/servlet/spring-boot/java/oauth2/resource-server/static[Static] +** https://github.com/spring-projects/spring-security-samples/tree/main/servlet/spring-boot/java/oauth2/resource-server/login[Login] + * RestClient - https://github.com/spring-projects/spring-security-samples/tree/main/servlet/spring-boot/java/oauth2/restclient[Spring Boot] * WebClient - https://github.com/spring-projects/spring-security-samples/tree/main/servlet/spring-boot/java/oauth2/webclient[Spring Boot] | https://github.com/spring-projects/spring-security-samples/tree/main/reactive/webflux/java/oauth2/webclient[WebFlux] diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/README.adoc b/servlet/spring-boot/java/oauth2/resource-server/login/README.adoc new file mode 100644 index 000000000..e939e33a6 --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/README.adoc @@ -0,0 +1,113 @@ += OAuth 2.0 Login and Resource Server Sample + +This sample demonstrates how to configure a single application that supports both: + +* Browser-based login using OAuth 2.0 Login (Google, GitHub, or a local Authorization Server) +* REST API access using OAuth 2.0 Bearer tokens + +This addresses the common pattern where an application starts as a full-stack web app and later needs to support native mobile clients or JavaScript applications that authenticate with Bearer tokens. + +== Security Filter Chains + +This application uses two `SecurityFilterChain` beans: + +* *Resource Server chain* (`@Order(1)`) -- matches `/api/**` requests, is stateless, and validates JWT Bearer tokens +* *Default chain* (`@Order(2)`) -- handles browser requests with OAuth 2.0 Login and server-side sessions + +The public home page (`/`) is accessible without authentication. The `/authenticated` page requires a browser login session. API endpoints under `/api/**` require a valid Bearer token. + +TIP: As an alternative, you can match the resource server chain using the `Authorization: Bearer` request header instead of a path prefix. See https://github.com/spring-projects/spring-security-samples/issues/99[issue #99] for the original discussion. + +== 1. Running the tests + +To run the tests, do: + +[source,bash] +---- +./gradlew check +---- + +Or import the project into your IDE and run the test classes from there. + +By default, integration tests use the `test` profile with an embedded mock Authorization Server. + +== 2. Running with Spring Authorization Server + +Before running this application with the default configuration, start the https://github.com/spring-projects/spring-security-samples/tree/main/servlet/spring-boot/java/oauth2/authorization-server[authorization-server sample] on port `9000`. + +Then run this sample: + +[source,bash] +---- +./gradlew bootRun +---- + +=== Browser login + +. Go to `http://127.0.0.1:8080/` -- the public home page +. Click *Login with Spring Authorization Server* +. After authenticating, visit `http://127.0.0.1:8080/authenticated` to see your profile + +=== Bearer token API + +Obtain a token from the Authorization Server: + +[source,bash] +---- +curl -X POST messaging-client:secret@localhost:9000/oauth2/token -d "grant_type=client_credentials" -d "scope=message:read" +---- + +Then call the API: + +[source,bash] +---- +export TOKEN=... +curl -H "Authorization: Bearer $TOKEN" localhost:8080/api +curl -H "Authorization: Bearer $TOKEN" localhost:8080/api/message +---- + +== 3. Configuring Google and GitHub + +To use Google or GitHub for browser login, update `application.yml` with your OAuth 2.0 credentials: + +[source,yaml] +---- +spring: + security: + oauth2: + client: + registration: + google: + client-id: google-client-id + client-secret: google-client-secret + github: + client-id: github-client-id + client-secret: github-client-secret +---- + +Set the redirect URI to `http://127.0.0.1:8080/login/oauth2/code/{registrationId}` in your provider's console. + +For Bearer token validation against Google, configure: + +[source,yaml] +---- +spring: + security: + oauth2: + resourceserver: + jwt: + issuer-uri: https://accounts.google.com +---- + +See the https://github.com/spring-projects/spring-security-samples/tree/main/servlet/spring-boot/java/oauth2/login[OAuth 2.0 Login sample] for detailed provider setup instructions. + +== 4. Running with the test profile + +To run with an embedded mock Authorization Server: + +[source,bash] +---- +./gradlew bootRun --args='--spring.profiles.active=test' +---- + +Use the hard-coded tokens from the integration tests to explore the API endpoints. diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/build.gradle b/servlet/spring-boot/java/oauth2/resource-server/login/build.gradle new file mode 100644 index 000000000..8ca689e17 --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/build.gradle @@ -0,0 +1,32 @@ +plugins { + alias(libs.plugins.org.springframework.boot) + alias(libs.plugins.io.spring.dependency.management) + id "nebula.integtest" version "8.2.0" + id 'java' +} + +repositories { + mavenCentral() + maven { url "https://repo.spring.io/milestone" } + maven { url "https://repo.spring.io/snapshot" } +} + + +dependencies { + implementation 'com.squareup.okhttp3:mockwebserver:5.1.0' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + 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.boot:spring-boot-webmvc-test' + testImplementation 'org.springframework.boot:spring-boot-security-test' + testImplementation 'org.springframework.security:spring-security-test' +} + +tasks.withType(Test).configureEach { + useJUnitPlatform() + +} diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/gradle.properties b/servlet/spring-boot/java/oauth2/resource-server/login/gradle.properties new file mode 100644 index 000000000..d4e1525af --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/gradle.properties @@ -0,0 +1,4 @@ +version=6.1.1 +spring-security.version=7.1.0-SNAPSHOT +org.gradle.jvmargs=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError +org.gradle.caching=true diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/gradle/wrapper/gradle-wrapper.jar b/servlet/spring-boot/java/oauth2/resource-server/login/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..249e5832f Binary files /dev/null and b/servlet/spring-boot/java/oauth2/resource-server/login/gradle/wrapper/gradle-wrapper.jar differ diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/gradle/wrapper/gradle-wrapper.properties b/servlet/spring-boot/java/oauth2/resource-server/login/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..cb4a103f3 --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https://services.gradle.org/distributions/gradle-8.14.4-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/gradlew b/servlet/spring-boot/java/oauth2/resource-server/login/gradlew new file mode 100644 index 000000000..a69d9cb6c --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/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/oauth2/resource-server/login/gradlew.bat b/servlet/spring-boot/java/oauth2/resource-server/login/gradlew.bat new file mode 100644 index 000000000..f127cfd49 --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/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/oauth2/resource-server/login/settings.gradle b/servlet/spring-boot/java/oauth2/resource-server/login/settings.gradle new file mode 100644 index 000000000..25192f018 --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/settings.gradle @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + maven { url 'https://repo.spring.io/milestone' } + maven { url "https://repo.spring.io/snapshot" } + } +} + +dependencyResolutionManagement { + versionCatalogs { + libs { + from(files("../../../../../../gradle/libs.versions.toml")) + } + } +} diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/src/integTest/java/example/OAuth2ResourceServerLoginApplicationITests.java b/servlet/spring-boot/java/oauth2/resource-server/login/src/integTest/java/example/OAuth2ResourceServerLoginApplicationITests.java new file mode 100644 index 000000000..5eb6f1163 --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/src/integTest/java/example/OAuth2ResourceServerLoginApplicationITests.java @@ -0,0 +1,131 @@ +/* + * 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. + */ + +package example; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import static org.hamcrest.Matchers.containsString; +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.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Integration tests for {@link OAuth2ResourceServerLoginApplication}. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class OAuth2ResourceServerLoginApplicationITests { + + String noScopesToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzdWJqZWN0IiwiZXhwIjoyMTY0MjQ1ODgwLCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiMDFkOThlZWEtNjc0MC00OGRlLTk4ODAtYzM5ZjgyMGZiNzVlIiwiY2xpZW50X2lkIjoibm9zY29wZXMiLCJzY29wZSI6WyJub25lIl19.VOzgGLOUuQ_R2Ur1Ke41VaobddhKgUZgto7Y3AGxst7SuxLQ4LgWwdSSDRx-jRvypjsCgYPbjAYLhn9nCbfwtCitkymUKUNKdebvVAI0y8YvliWTL5S-GiJD9dN8SSsXUla9A4xB_9Mt5JAlRpQotQSCLojVSKQmjhMpQWmYAlKVjnlImoRwQFPI4w3Ijn4G4EMTKWUYRfrD0-WNT9ZYWBeza6QgV6sraP7ToRB3eQLy2p04cU40X-RHLeYCsMBfxsMMh89CJff-9tn7VDKi1hAGc_Lp9yS9ZaItJuFJTjf8S_vsjVB1nBhvdS_6IED_m_fOU52KiGSO2qL6shxHvg"; + + String messageReadToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzdWJqZWN0IiwiZXhwIjoyMTY0MjQ1NjQ4LCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiY2I1ZGMwNDYtMDkyMi00ZGJmLWE5MzAtOGI2M2FhZTYzZjk2IiwiY2xpZW50X2lkIjoicmVhZGVyIiwic2NvcGUiOlsibWVzc2FnZTpyZWFkIl19.Pre2ksnMiOGYWQtuIgHB0i3uTnNzD0SMFM34iyQJHK5RLlSjge08s9qHdx6uv5cZ4gZm_cB1D6f4-fLx76bCblK6mVcabbR74w_eCdSBXNXuqG-HNrOYYmmx5iJtdwx5fXPmF8TyVzsq_LvRm_LN4lWNYquT4y36Tox6ZD3feYxXvHQ3XyZn9mVKnlzv-GCwkBohCR3yPow5uVmr04qh_al52VIwKMrvJBr44igr4fTZmzwRAZmQw5rZeyep0b4nsCjadNcndHtMtYKNVuG5zbDLsB7GGvilcI9TDDnUXtwthB_3iq32DAd9x8wJmJ5K8gmX6GjZFtYzKk_zEboXoQ"; + + String messageWriteToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzdWJqZWN0IiwiZXhwIjoyMTY0MjQzOTA0LCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiZGI4ZjgwMzQtM2VlNy00NjBjLTk3NTEtMDJiMDA1OWI5NzA4IiwiY2xpZW50X2lkIjoid3JpdGVyIiwic2NvcGUiOlsibWVzc2FnZTp3cml0ZSJdfQ.USvpx_ntKXtchLmc93auJq0qSav6vLm4B7ItPzhrDH2xmogBP35eKeklwXK5GCb7ck1aKJV5SpguBlTCz0bZC1zAWKB6gyFIqedALPAran5QR-8WpGfl0wFqds7d8Jw3xmpUUBduRLab9hkeAhgoVgxevc8d6ITM7kRnHo5wT3VzvBU8DquedVXm5fbBnRPgG4_jOWJKbqYpqaR2z2TnZRWh3CqL82Orh1Ww1dJYF_fae1dTVV4tvN5iSndYcGxMoBaiw3kRRi6EyNxnXnt1pFtZqc1f6D9x4AHiri8_vpBp2vwG5OfQD5-rrleP_XlIB3rNQT7tu3fiqu4vUzQaEg"; + + @Autowired + MockMvc mvc; + + @Test + void indexWhenAnonymousThenAllows() throws Exception { + // @formatter:off + this.mvc.perform(get("/")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("OAuth 2.0 Login and Resource Server"))); + // @formatter:on + } + + @Test + void authenticatedWhenAnonymousThenRedirectsToLogin() throws Exception { + // @formatter:off + this.mvc.perform(get("/authenticated")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/login")); + // @formatter:on + } + + @Test + void performWhenValidBearerTokenThenAllows() throws Exception { + // @formatter:off + this.mvc.perform(get("/api").with(bearerToken(this.noScopesToken))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Hello, subject!"))); + // @formatter:on + } + + @Test + void performWhenValidBearerTokenThenScopedRequestsAlsoWork() throws Exception { + // @formatter:off + this.mvc.perform(get("/api/message").with(bearerToken(this.messageReadToken))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("secret message"))); + // @formatter:on + } + + @Test + void performWhenInsufficientlyScopedBearerTokenThenDeniesScopedMethodAccess() throws Exception { + // @formatter:off + this.mvc.perform(get("/api/message").with(bearerToken(this.noScopesToken))) + .andExpect(status().isForbidden()) + .andExpect(header().string(HttpHeaders.WWW_AUTHENTICATE, + containsString("Bearer error=\"insufficient_scope\""))); + // @formatter:on + } + + @Test + void performPostWhenValidBearerTokenThenScopedRequestsAlsoWork() throws Exception { + // @formatter:off + this.mvc.perform(post("/api/message").content("example message") + .with(bearerToken(this.messageWriteToken))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Message was created"))); + // @formatter:on + } + + private static BearerTokenRequestPostProcessor bearerToken(String token) { + return new BearerTokenRequestPostProcessor(token); + } + + private static class BearerTokenRequestPostProcessor implements RequestPostProcessor { + + private final String token; + + BearerTokenRequestPostProcessor(String token) { + this.token = token; + } + + @Override + public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) { + request.addHeader("Authorization", "Bearer " + this.token); + return request; + } + + } + +} diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/example/OAuth2ResourceServerLoginApplication.java b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/example/OAuth2ResourceServerLoginApplication.java new file mode 100644 index 000000000..1efb7407e --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/example/OAuth2ResourceServerLoginApplication.java @@ -0,0 +1,32 @@ +/* + * 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. + */ + +package example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * OAuth2 Login and Resource Server application. + */ +@SpringBootApplication +public class OAuth2ResourceServerLoginApplication { + + public static void main(String[] args) { + SpringApplication.run(OAuth2ResourceServerLoginApplication.class, args); + } + +} diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/example/OAuth2ResourceServerLoginSecurityConfiguration.java b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/example/OAuth2ResourceServerLoginSecurityConfiguration.java new file mode 100644 index 000000000..7b7964c77 --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/example/OAuth2ResourceServerLoginSecurityConfiguration.java @@ -0,0 +1,80 @@ +/* + * 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. + */ + +package example; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.web.SecurityFilterChain; + +import static org.springframework.security.config.Customizer.withDefaults; + +/** + * Security configuration for OAuth2 Login and Resource Server. + */ +@Configuration +@EnableWebSecurity +public class OAuth2ResourceServerLoginSecurityConfiguration { + + @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") + String jwkSetUri; + + @Bean + @Order(1) + SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .securityMatcher("/api/**") + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers(HttpMethod.GET, "/api/message/**").hasAuthority("SCOPE_message:read") + .requestMatchers(HttpMethod.POST, "/api/message/**").hasAuthority("SCOPE_message:write") + .anyRequest().authenticated() + ) + .csrf((csrf) -> csrf.disable()) + .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .oauth2ResourceServer((oauth2) -> oauth2.jwt(withDefaults())); + // @formatter:on + return http.build(); + } + + @Bean + @Order(2) + SecurityFilterChain defaultFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/", "/login**", "/webjars/**", "/assets/**", "/error").permitAll() + .anyRequest().authenticated() + ) + .oauth2Login(withDefaults()); + // @formatter:on + return http.build(); + } + + @Bean + JwtDecoder jwtDecoder() { + return NimbusJwtDecoder.withJwkSetUri(this.jwkSetUri).build(); + } + +} diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/example/api/MessageController.java b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/example/api/MessageController.java new file mode 100644 index 000000000..51cbf2d05 --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/example/api/MessageController.java @@ -0,0 +1,49 @@ +/* + * 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. + */ + +package example.api; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Bearer token REST API controller. + */ +@RestController +@RequestMapping("/api") +public class MessageController { + + @GetMapping + public String index(@AuthenticationPrincipal Jwt jwt) { + return String.format("Hello, %s!", jwt.getSubject()); + } + + @GetMapping("/message") + public String message() { + return "secret message"; + } + + @PostMapping("/message") + public String createMessage(@RequestBody String message) { + return String.format("Message was created. Content: %s", message); + } + +} diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/example/web/AuthenticatedController.java b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/example/web/AuthenticatedController.java new file mode 100644 index 000000000..a8d3467c5 --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/example/web/AuthenticatedController.java @@ -0,0 +1,43 @@ +/* + * 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. + */ + +package example.web; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * Authenticated MVC page controller. + */ +@Controller +public class AuthenticatedController { + + @GetMapping("/authenticated") + public String authenticated(Model model, + @RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient authorizedClient, + @AuthenticationPrincipal OAuth2User oauth2User) { + model.addAttribute("userName", oauth2User.getName()); + model.addAttribute("clientName", authorizedClient.getClientRegistration().getClientName()); + model.addAttribute("userAttributes", oauth2User.getAttributes()); + return "authenticated"; + } + +} diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/example/web/HomeController.java b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/example/web/HomeController.java new file mode 100644 index 000000000..6e62746eb --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/example/web/HomeController.java @@ -0,0 +1,33 @@ +/* + * 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. + */ + +package example.web; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * Public home page controller. + */ +@Controller +public class HomeController { + + @GetMapping("/") + public String index() { + return "index"; + } + +} diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/org/springframework/boot/env/MockWebServerEnvironmentPostProcessor.java b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/org/springframework/boot/env/MockWebServerEnvironmentPostProcessor.java new file mode 100644 index 000000000..f6781098c --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/org/springframework/boot/env/MockWebServerEnvironmentPostProcessor.java @@ -0,0 +1,42 @@ +/* + * Copyright 2002-2018 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.springframework.boot.env; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.boot.SpringApplication; +import org.springframework.core.env.ConfigurableEnvironment; + +/** + * Adds {@link MockWebServerPropertySource} to the environment. + * + * @author Rob Winch + */ +public class MockWebServerEnvironmentPostProcessor implements EnvironmentPostProcessor, DisposableBean { + + private final MockWebServerPropertySource propertySource = new MockWebServerPropertySource(); + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + environment.getPropertySources().addFirst(this.propertySource); + } + + @Override + public void destroy() throws Exception { + this.propertySource.destroy(); + } + +} diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/org/springframework/boot/env/MockWebServerPropertySource.java b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/org/springframework/boot/env/MockWebServerPropertySource.java new file mode 100644 index 000000000..a3cfb7767 --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/org/springframework/boot/env/MockWebServerPropertySource.java @@ -0,0 +1,121 @@ +/* + * Copyright 2002-2019 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.springframework.boot.env; + +import java.io.IOException; + +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.core.env.PropertySource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +/** + * Adds value for mockwebserver.url property. + * + * @author Rob Winch + */ +public class MockWebServerPropertySource extends PropertySource implements DisposableBean { + + private static final MockResponse JWKS_RESPONSE = response( + "{ \"keys\": [ { \"kty\": \"RSA\", \"e\": \"AQAB\", \"n\": \"jvBtqsGCOmnYzwe_-HvgOqlKk6HPiLEzS6uCCcnVkFXrhnkPMZ-uQXTR0u-7ZklF0XC7-AMW8FQDOJS1T7IyJpCyeU4lS8RIf_Z8RX51gPGnQWkRvNw61RfiSuSA45LR5NrFTAAGoXUca_lZnbqnl0td-6hBDVeHYkkpAsSck1NPhlcsn-Pvc2Vleui_Iy1U2mzZCM1Vx6Dy7x9IeP_rTNtDhULDMFbB_JYs-Dg6Zd5Ounb3mP57tBGhLYN7zJkN1AAaBYkElsc4GUsGsUWKqgteQSXZorpf6HdSJsQMZBDd7xG8zDDJ28hGjJSgWBndRGSzQEYU09Xbtzk-8khPuw\" } ] }", + 200); + + private static final MockResponse NOT_FOUND_RESPONSE = response( + "{ \"message\" : \"This mock authorization server responds to just one request: GET /.well-known/jwks.json.\" }", + 404); + + /** + * Name of the random {@link PropertySource}. + */ + public static final String MOCK_WEB_SERVER_PROPERTY_SOURCE_NAME = "mockwebserver"; + + private static final String NAME = "mockwebserver.url"; + + private static final Log logger = LogFactory.getLog(MockWebServerPropertySource.class); + + private boolean started; + + public MockWebServerPropertySource() { + super(MOCK_WEB_SERVER_PROPERTY_SOURCE_NAME, new MockWebServer()); + } + + @Override + public Object getProperty(String name) { + if (!name.equals(NAME)) { + return null; + } + if (logger.isTraceEnabled()) { + logger.trace("Looking up the url for '" + name + "'"); + } + String url = getUrl(); + return url; + } + + @Override + public void destroy() throws Exception { + getSource().shutdown(); + } + + /** + * Get's the URL (i.e. "http://localhost:123456") + * @return the url with the dynamic port + */ + private String getUrl() { + MockWebServer mockWebServer = getSource(); + if (!this.started) { + intializeMockWebServer(mockWebServer); + } + String url = mockWebServer.url("").url().toExternalForm(); + return url.substring(0, url.length() - 1); + } + + private void intializeMockWebServer(MockWebServer mockWebServer) { + Dispatcher dispatcher = new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + if ("/.well-known/jwks.json".equals(request.getPath())) { + return JWKS_RESPONSE; + } + + return NOT_FOUND_RESPONSE; + } + }; + + mockWebServer.setDispatcher(dispatcher); + try { + mockWebServer.start(); + this.started = true; + } + catch (IOException ex) { + throw new RuntimeException("Could not start " + mockWebServer, ex); + } + } + + private static MockResponse response(String body, int status) { + return new MockResponse().setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .setResponseCode(status) + .setBody(body); + } + +} diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/org/springframework/boot/env/package-info.java b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/org/springframework/boot/env/package-info.java new file mode 100644 index 000000000..d1203050d --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/java/org/springframework/boot/env/package-info.java @@ -0,0 +1,24 @@ +/* + * Copyright 2002-2018 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. + */ + +/** + * This provides integration of a {@link okhttp3.mockwebserver.MockWebServer} and the + * {@link org.springframework.core.env.Environment}. + * + * @author Rob Winch + */ + +package org.springframework.boot.env; diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/src/main/resources/META-INF/spring.factories b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..37b447c97 --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.boot.env.EnvironmentPostProcessor=org.springframework.boot.env.MockWebServerEnvironmentPostProcessor diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/src/main/resources/application-test.yml b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/resources/application-test.yml new file mode 100644 index 000000000..2a6d127d3 --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/resources/application-test.yml @@ -0,0 +1,6 @@ +spring: + security: + oauth2: + resourceserver: + jwt: + jwk-set-uri: ${mockwebserver.url}/.well-known/jwks.json diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/src/main/resources/application.yml b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/resources/application.yml new file mode 100644 index 000000000..60bd57719 --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/resources/application.yml @@ -0,0 +1,39 @@ +server: + port: 8080 + +logging: + level: + root: INFO + org.springframework.web: INFO + org.springframework.security: INFO + +spring: + thymeleaf: + cache: false + security: + oauth2: + client: + registration: + login-client: + provider: spring + client-id: login-client + client-secret: openid-connect + client-authentication-method: client_secret_basic + authorization-grant-type: authorization_code + redirect-uri: http://127.0.0.1:8080/login/oauth2/code/login-client + scope: openid,profile + client-name: Spring + google: + client-id: your-app-client-id + client-secret: your-app-client-secret + github: + client-id: your-app-client-id + client-secret: your-app-client-secret + provider: + spring: + authorization-uri: http://localhost:9000/oauth2/authorize + token-uri: http://localhost:9000/oauth2/token + jwk-set-uri: http://localhost:9000/oauth2/jwks + resourceserver: + jwt: + jwk-set-uri: http://localhost:9000/oauth2/jwks diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/src/main/resources/templates/authenticated.html b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/resources/templates/authenticated.html new file mode 100644 index 000000000..a3c2f811c --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/resources/templates/authenticated.html @@ -0,0 +1,34 @@ + + + + Authenticated + + + +
+
+ User: +
+
 
+
+
+ +
+
+
+

Authenticated Page

+
+ You are successfully logged in as + via the OAuth 2.0 Client +
+
 
+
+ User Attributes: + +
+ + diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/src/main/resources/templates/index.html b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/resources/templates/index.html new file mode 100644 index 000000000..167fe0d3f --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/src/main/resources/templates/index.html @@ -0,0 +1,23 @@ + + + + OAuth 2.0 Login and Resource Server + + + +

OAuth 2.0 Login and Resource Server

+

This application supports browser login via OAuth 2.0 and API access via Bearer tokens.

+
+

Login with Spring Authorization Server

+

Login with Google

+

Login with GitHub

+
+
+

You are logged in as .

+

View authenticated page

+
+ +
+
+ + diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/src/test/java/example/api/MessageControllerTests.java b/servlet/spring-boot/java/oauth2/resource-server/login/src/test/java/example/api/MessageControllerTests.java new file mode 100644 index 000000000..ad4c51dca --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/src/test/java/example/api/MessageControllerTests.java @@ -0,0 +1,87 @@ +/* + * 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. + */ + +package example.api; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import example.OAuth2ResourceServerLoginSecurityConfiguration; + +import static org.hamcrest.CoreMatchers.is; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +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.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests for {@link MessageController}. + */ +@WebMvcTest(MessageController.class) +@Import(OAuth2ResourceServerLoginSecurityConfiguration.class) +@TestPropertySource(properties = "spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost/.well-known/jwks.json") +class MessageControllerTests { + + @Autowired + MockMvc mockMvc; + + @Test + void indexGreetsAuthenticatedUser() throws Exception { + // @formatter:off + this.mockMvc.perform(get("/api").with(jwt().jwt((jwt) -> jwt.subject("ch4mpy")))) + .andExpect(content().string(is("Hello, ch4mpy!"))); + // @formatter:on + } + + @Test + void messageCanBeReadWithScopeMessageReadAuthority() throws Exception { + // @formatter:off + this.mockMvc.perform(get("/api/message").with(jwt().jwt((jwt) -> jwt.claim("scope", "message:read")))) + .andExpect(content().string(is("secret message"))); + + this.mockMvc.perform(get("/api/message") + .with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_message:read")))) + .andExpect(content().string(is("secret message"))); + // @formatter:on + } + + @Test + void messageCanNotBeReadWithoutScopeMessageReadAuthority() throws Exception { + // @formatter:off + this.mockMvc.perform(get("/api/message").with(jwt())) + .andExpect(status().isForbidden()); + // @formatter:on + } + + @Test + void messageCanBeCreatedWithScopeMessageWriteAuthority() throws Exception { + // @formatter:off + this.mockMvc.perform(post("/api/message") + .content("Hello message") + .with(jwt().jwt((jwt) -> jwt.claim("scope", "message:write")))) + .andExpect(status().isOk()) + .andExpect(content().string(is("Message was created. Content: Hello message"))); + // @formatter:on + } + +} diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/src/test/java/example/web/AuthenticatedControllerTests.java b/servlet/spring-boot/java/oauth2/resource-server/login/src/test/java/example/web/AuthenticatedControllerTests.java new file mode 100644 index 000000000..d7a782d74 --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/src/test/java/example/web/AuthenticatedControllerTests.java @@ -0,0 +1,89 @@ +/* + * 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. + */ + +package example.web; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Bean; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oauth2Login; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +/** + * Tests for {@link AuthenticatedController}. + */ +@WebMvcTest(AuthenticatedController.class) +class AuthenticatedControllerTests { + + @Autowired + MockMvc mvc; + + @Test + void authenticatedWhenLoggedInThenReturnsUserAndClient() throws Exception { + // @formatter:off + this.mvc.perform(get("/authenticated").with(oauth2Login())) + .andExpect(view().name("authenticated")) + .andExpect(model().attribute("userName", "user")) + .andExpect(model().attribute("clientName", "test")) + .andExpect(model().attribute("userAttributes", Collections.singletonMap("sub", "user"))); + // @formatter:on + } + + @Test + void authenticatedWhenOverridingClientRegistrationThenReturnsAccordingly() throws Exception { + // @formatter:off + ClientRegistration clientRegistration = ClientRegistration.withRegistrationId("test") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationUri("https://authorization-uri.example.org") + .clientId("my-client-id") + .clientName("my-client-name") + .redirectUri("{baseUrl}/login/oauth2/code/test") + .tokenUri("https://token-uri.example.org") + .build(); + + this.mvc.perform(get("/authenticated").with(oauth2Login() + .clientRegistration(clientRegistration) + .attributes((attributes) -> attributes.put("sub", "spring-security")))) + .andExpect(model().attribute("userName", "spring-security")) + .andExpect(model().attribute("clientName", "my-client-name")) + .andExpect(model().attribute("userAttributes", Collections.singletonMap("sub", "spring-security"))); + // @formatter:on + } + + @TestConfiguration + static class AuthorizedClient { + + @Bean + OAuth2AuthorizedClientRepository authorizedClientRepository() { + return new HttpSessionOAuth2AuthorizedClientRepository(); + } + + } + +} diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/src/test/java/example/web/HomeControllerTests.java b/servlet/spring-boot/java/oauth2/resource-server/login/src/test/java/example/web/HomeControllerTests.java new file mode 100644 index 000000000..42c4986e8 --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/src/test/java/example/web/HomeControllerTests.java @@ -0,0 +1,49 @@ +/* + * 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. + */ + +package example.web; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +/** + * Tests for {@link HomeController}. + */ +@WebMvcTest(HomeController.class) +@AutoConfigureMockMvc(addFilters = false) +class HomeControllerTests { + + @Autowired + MockMvc mvc; + + @Test + void indexWhenAnonymousThenReturnsHomePage() throws Exception { + // @formatter:off + this.mvc.perform(get("/")) + .andExpect(status().isOk()) + .andExpect(view().name("index")); + // @formatter:on + } + +} diff --git a/servlet/spring-boot/java/oauth2/resource-server/login/src/test/resources/validjwt.json b/servlet/spring-boot/java/oauth2/resource-server/login/src/test/resources/validjwt.json new file mode 100644 index 000000000..fe73b5f0b --- /dev/null +++ b/servlet/spring-boot/java/oauth2/resource-server/login/src/test/resources/validjwt.json @@ -0,0 +1 @@ +{ "sub" : "ch4mpy" } \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index b973f4494..c967da3ff 100644 --- a/settings.gradle +++ b/settings.gradle @@ -65,6 +65,7 @@ include ":servlet:spring-boot:java:ldap" include ":servlet:spring-boot:java:oauth2:authorization-server" include ":servlet:spring-boot:java:oauth2:login" include ":servlet:spring-boot:java:oauth2:resource-server:hello-security" +include ":servlet:spring-boot:java:oauth2:resource-server:login" include ":servlet:spring-boot:java:oauth2:resource-server:jwe" include ":servlet:spring-boot:java:oauth2:resource-server:multi-tenancy" include ":servlet:spring-boot:java:oauth2:resource-server:opaque"