├── README.md ├── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── pom.xml ├── settings.gradle └── src ├── main └── java │ └── io │ └── rapha │ └── spring │ └── reactive │ └── security │ ├── SecuredRestApplication.java │ ├── api │ └── MessageController.java │ ├── auth │ ├── basic │ │ └── BasicAuthenticationSuccessHandler.java │ ├── bearer │ │ ├── BearerTokenReactiveAuthenticationManager.java │ │ └── ServerHttpBearerAuthenticationConverter.java │ └── jwt │ │ ├── AuthorizationHeaderPayload.java │ │ ├── JWTCustomSigner.java │ │ ├── JWTCustomVerifier.java │ │ ├── JWTSecrets.java │ │ ├── JWTTokenService.java │ │ └── UsernamePasswordAuthenticationBearer.java │ ├── domain │ └── FormattedMessage.java │ └── service │ └── MessageService.java └── test └── java └── io └── rapha └── spring └── reactive └── security └── SecuredRestApplicationTest.java /README.md: -------------------------------------------------------------------------------- 1 | # Authentication and Authorization using JWT with Spring WebFlux and Spring Security Reactive 2 | 3 | ### Nice Docs to Read First 4 | 5 | Before getting started I suggest you go through the next reference 6 | 7 | [Spring Webflux](https://docs.spring.io/spring/docs/5.1.0.RELEASE/spring-framework-reference/web-reactive.html#spring-webflux) 8 | 9 | [Spring Security Reactive](https://docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#reactive-applications) 10 | 11 | [Spring Security Architecture](https://spring.io/guides/topicals/spring-security-architecture) 12 | 13 | ### Enable Spring WebFlux Security 14 | First enable Webflux Security in your application with `@EnableWebFluxSecurity` 15 | 16 | ```java 17 | @SpringBootApplication 18 | @EnableWebFluxSecurity 19 | public class SecuredRestApplication { 20 | .... 21 | } 22 | ``` 23 | 24 | ### Create an InMemory UserDetailsService 25 | 26 | Define a custom `UserDetailsService` bean where an User with password and 27 | initial roles is added: 28 | 29 | 30 | ```java 31 | @Bean 32 | public MapReactiveUserDetailsService userDetailsRepository() { 33 | UserDetails user = User.withDefaultPasswordEncoder() 34 | .username("user") 35 | .password("user") 36 | .roles("USER", "ADMIN") 37 | .build(); 38 | return new MapReactiveUserDetailsService(user); 39 | } 40 | ``` 41 | 42 | In this example user information will be stored in memory using a `Map` but it can be replaced by different strategies. 43 | 44 | Before getting a Json Web Token an user should use another authentication mechanism, for example HTTP Basic Authentication and provided the right credentials a JWT will be issued which can be used to perform future API calls by changing the `Authetication` method from Basic to Bearer. 45 | 46 | 47 | ### Starting from Basic Authentication 48 | 49 | Below there's a simple way to define Basic Authentication with Spring Security. Customization is needed in order to return a JWT on succesful authentication. 50 | 51 | ```java 52 | @Bean 53 | public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { 54 | http 55 | .authorizeExchange() 56 | .anyExchange().authenticated() 57 | .and() 58 | .httpBasic(); // Pure basic is not enough for us! 59 | 60 | return http.build(); 61 | } 62 | ``` 63 | 64 | ### Inspect AuthenticationFilter, improvise, adapt overcome 65 | 66 | With Spring Reactive, requests go through a chain of filters, each filter can aprove or discard requests according to different rules. Advantage is taken to perform request authentication. 67 | Different types of `WebFilter` are grouped by a `WebFilterChain`, in Spring Security there's `AuthenticationWebFilter` which outlines how authentication should be performed on requests matching a criteria. 68 | 69 | `AuthenticationWebFilter` implements all the required behavior for Basic Authentication, take a look at it: 70 | 71 | 72 | ```java 73 | public class AuthenticationWebFilter implements WebFilter { 74 | 75 | private final ReactiveAuthenticationManager authenticationManager; 76 | 77 | private ServerAuthenticationSuccessHandler authenticationSuccessHandler = new WebFilterChainServerAuthenticationSuccessHandler(); 78 | // WE NEED A DIFFERENT SUCCESS HANDLER!!!!!! 79 | 80 | private Function> authenticationConverter = new ServerHttpBasicAuthenticationConverter(); 81 | 82 | private ServerAuthenticationFailureHandler authenticationFailureHandler = new ServerAuthenticationEntryPointFailureHandler(new HttpBasicServerAuthenticationEntryPoint()); 83 | 84 | private ServerSecurityContextRepository securityContextRepository = NoOpServerSecurityContextRepository.getInstance(); 85 | 86 | private ServerWebExchangeMatcher requiresAuthenticationMatcher = ServerWebExchangeMatchers.anyExchange(); 87 | 88 | .... 89 | ``` 90 | 91 | The behavior that needs to be changed is what happens once an user has been authenticated using user/password credentials. 92 | The `WebFilterChainServerAuthenticationSuccessHandler` will pass the request through the filter chain. A custom implementation is needed in this step where a Json Web Token is generated and added to the response, then the exchange will follow its way. 93 | 94 | 95 | ### Create custom SuccessHandler to make Basic Authentication return a Json Web Token 96 | 97 | Create a custom `ServerAuthenticationSuccessHandler`, this handler is executed once the authentication with user/password has been successful, it receives the current exchange and `Authentication` object. A JWT is generated using the `Exchange` and `Authentication` object. In this way `BasicAuthenticationSuccessHandler` implements the desired behavior: 98 | 99 | ```java 100 | ... 101 | @Override 102 | public Mono onAuthenticationSuccess(WebFilterExchange webFilterExchange, Authentication authentication) { 103 | // Create and attach a JWT before passing the exchange to the filter chain 104 | ServerWebExchange exchange = webFilterExchange.getExchange(); 105 | exchange.getResponse() 106 | .getHeaders() 107 | .add(HttpHeaders.AUTHORIZATION, getHttpAuthHeaderValue(authentication)); 108 | return webFilterExchange.getChain().filter(exchange); 109 | } 110 | ... 111 | ``` 112 | The response from the current exchange is updated with the HTTP Authorization header with a new JWT that contains data from the `Authentication` object. 113 | 114 | 115 | ### Create a Basic Authentication filter that returns a JWT 116 | 117 | Now create a new `AuthenticationFilter` with a custom handler: 118 | 119 | ```java 120 | ... 121 | UserDetailsRepositoryReactiveAuthenticationManager authManager; 122 | AuthenticationWebFilter basicAuthenticationFilter; 123 | ServerAuthenticationSuccessHandler successHandler; 124 | 125 | authManager = new UserDetailsRepositoryReactiveAuthenticationManager(userDetailsRepository()); 126 | successHandler = new BasicAuthenticationSuccessHandler(); 127 | 128 | basicAuthenticationFilter = new AuthenticationWebFilter(authManager); 129 | basicAuthenticationFilter.setAuthenticationSuccessHandler(successHandler); 130 | 131 | ... 132 | ``` 133 | 134 | 135 | ### Add this filter to ServerHttpSecurity 136 | 137 | 138 | Add this to our `ServerHttpSecurity`: 139 | 140 | ```java 141 | ... 142 | http 143 | .authorizeExchange() 144 | .pathMatchers("/login", "/") 145 | .authenticated() 146 | .and() 147 | .addFilterAt(basicAuthenticationFilter, SecurityWebFiltersOrder.HTTP_BASIC) 148 | ... 149 | ``` 150 | 151 | The functionality that returns a JWT when authenticating using User and Password is now implemented. 152 | 153 | 154 | ## Handle Requests with Bearer token Authorization Header 155 | 156 | Now let's build the functionality that will take a request with the HTTP Authorization Header containing a Bearer token. 157 | The same way the `AuthenticationWebFilter` was customized before, customize another to create a new filter. 158 | 159 | When using JWT all information needed to authenticate and authorize a user lives within a token. 160 | Perform the next steps: 161 | 162 | Filter requests containing a Bearer token within its HTTP Authorization Header, verify that are well formed, confirm that it has a valid signature and then build an `Authorization` object with all information contained in the payload. If the JWT is invalid, there won't be `Authorization` resulting in an unauthorized response. 163 | 164 | Because all information needed is contained in the JWT payload all invalid tokens will be rejected in the filtering step, but the contract defined by the `AuthenticationWebFilter` requires a non null `AuthenticationManager`. Create a dummy manager that will authenticate all exchanges. Why? Because all invalid JWT did not resulted in an authorization object and did not make it into this step. 165 | 166 | 167 | ### Generate an Authentication object using only the information contained in the token 168 | 169 | Create a converter `ServerHttpBearerAuthenticationConverter` that takes a request `ServerWebExchange` and returns an `Authorization` object created with the information extracted from the token: 170 | 171 | ```java 172 | ... 173 | public Mono apply(ServerWebExchange serverWebExchange) { 174 | return Mono.justOrEmpty(serverWebExchange) 175 | .flatMap(AuthorizationHeaderPayload::extract) 176 | .filter(matchBearerLength) 177 | .flatMap(isolateBearerValue) 178 | .flatMap(jwtVerifier::check) 179 | .flatMap(UsernamePasswordAuthenticationBearer::create).log(); 180 | } 181 | ... 182 | ``` 183 | 184 | ### Create a dummy AuthenticationManager 185 | 186 | Now implement a dummy `AuthenticationManager` called `BearerTokenReactiveAuthenticationManager`: 187 | 188 | ```java 189 | ... 190 | public Mono authenticate(Authentication authentication) { 191 | return Mono.just(authentication); 192 | } 193 | 194 | ... 195 | ``` 196 | 197 | ### Add the new filter to ServerHttpSecurity 198 | 199 | Finally chain this filter in the `ServerHttpSecurity` configuration object: 200 | 201 | ```java 202 | ... 203 | public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { 204 | 205 | http 206 | .authorizeExchange() 207 | .pathMatchers("/login", "/") 208 | .authenticated() 209 | .and() 210 | .addFilterAt(basicAuthenticationFilter(), SecurityWebFiltersOrder.HTTP_BASIC) 211 | .authorizeExchange() 212 | .pathMatchers("/api/**") 213 | .authenticated() 214 | .and() 215 | .addFilterAt(bearerAuthenticationFilter(), SecurityWebFiltersOrder.AUTHENTICATION); 216 | 217 | return http.build(); 218 | } 219 | ... 220 | ``` 221 | 222 | 223 | ### Create a REST Controller and configure access rules 224 | 225 | ```java 226 | ... 227 | @GetMapping("/api/private") 228 | @PreAuthorize("hasRole('USER')") 229 | public Flux privateMessage() { 230 | return messageService.getCustomMessage("User"); 231 | } 232 | 233 | ... 234 | ``` 235 | 236 | 237 | ### Run the Application 238 | 239 | With Maven 240 | ```shell 241 | $ mvn spring-boot:run 242 | ``` 243 | 244 | With Gradle 245 | ```shell 246 | $ ./gradlew bootRun 247 | ``` 248 | 249 | 250 | ### Test it 251 | 252 | Login using HTTP Basic 253 | 254 | ```shell 255 | $ curl -v -u user:user localhost:8080/login 256 | ``` 257 | 258 | Inspect the response contents and find the authorization header. 259 | It should look like: 260 | 261 | ```shell 262 | Authorization: Bearer eyJhbGciOiJIUzI1Ni..... 263 | ``` 264 | 265 | Use that in another request: 266 | 267 | ```shell 268 | $ curl -v -H "Authorization: Bearer eyJhbGciOiJIUzI1Ni....." localhost:8080/api/admin 269 | ``` 270 | 271 | You should be able to consume the API 272 | 273 | ### That's all 274 | 275 | Hope you enjoy it. 276 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext { 3 | springBootVersion = '2.1.1.RELEASE' 4 | } 5 | repositories { 6 | mavenCentral() 7 | } 8 | dependencies { 9 | classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}") 10 | } 11 | } 12 | 13 | apply plugin: 'java' 14 | apply plugin: 'eclipse' 15 | apply plugin: 'org.springframework.boot' 16 | apply plugin: 'io.spring.dependency-management' 17 | 18 | group = 'io.rapha.spring' 19 | version = '0.4.2-SNAPSHOT' 20 | sourceCompatibility = 1.8 21 | 22 | repositories { 23 | mavenCentral() 24 | } 25 | 26 | 27 | dependencies { 28 | implementation('org.springframework.boot:spring-boot-starter-security') 29 | implementation('org.springframework.boot:spring-boot-starter-webflux') 30 | compile group: 'com.nimbusds', name: 'nimbus-jose-jwt', version: '6.4.2' 31 | testImplementation('org.springframework.boot:spring-boot-starter-test') 32 | testImplementation('io.projectreactor:reactor-test') 33 | testImplementation('org.springframework.security:spring-security-test') 34 | } 35 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raphaelDL/spring-webflux-security-jwt/d6adb5a043f7ae147e72e2f66dd3fafe05902158/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.0-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS='"-Xmx64m"' 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS="-Xmx64m" 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | io.rapha.spring 7 | reactive-spring-jwt 8 | 0.4.2-SNAPSHOT 9 | jar 10 | 11 | reactive-spring-jwt 12 | Demo Project for Spring Reactive 13 | 14 | 15 | org.springframework.boot 16 | spring-boot-starter-parent 17 | 2.1.1.RELEASE 18 | 19 | 20 | 21 | 22 | UTF-8 23 | UTF-8 24 | 1.8 25 | 26 | 27 | 28 | 29 | org.springframework.boot 30 | spring-boot-starter-security 31 | 32 | 33 | org.springframework.boot 34 | spring-boot-starter-webflux 35 | 36 | 37 | com.nimbusds 38 | nimbus-jose-jwt 39 | 6.4.2 40 | 41 | 42 | org.springframework.boot 43 | spring-boot-starter-test 44 | test 45 | 46 | 47 | io.projectreactor 48 | reactor-test 49 | test 50 | 51 | 52 | org.springframework.security 53 | spring-security-test 54 | test 55 | 56 | 57 | 58 | 59 | 60 | org.springframework.boot 61 | spring-boot-maven-plugin 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'reactive-spring-jwt' 2 | -------------------------------------------------------------------------------- /src/main/java/io/rapha/spring/reactive/security/SecuredRestApplication.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Permission is hereby granted, free of charge, to any person obtaining a 3 | * copy of this software and associated documentation files (the "Software"), 4 | * to deal in the Software without restriction, including without limitation 5 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, 6 | * and/or sell copies of the Software, and to permit persons to whom the 7 | * Software is furnished to do so, subject to the following conditions: 8 | * 9 | * The above copyright notice and this permission notice shall be included 10 | * in all copies or substantial portions of the Software. 11 | * 12 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL 15 | * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | * SOFTWARE. 19 | */ 20 | package io.rapha.spring.reactive.security; 21 | 22 | import io.rapha.spring.reactive.security.auth.basic.BasicAuthenticationSuccessHandler; 23 | import io.rapha.spring.reactive.security.auth.bearer.BearerTokenReactiveAuthenticationManager; 24 | import io.rapha.spring.reactive.security.auth.bearer.ServerHttpBearerAuthenticationConverter; 25 | import org.springframework.boot.SpringApplication; 26 | import org.springframework.boot.autoconfigure.SpringBootApplication; 27 | import org.springframework.context.annotation.Bean; 28 | import org.springframework.security.authentication.ReactiveAuthenticationManager; 29 | import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager; 30 | import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; 31 | import org.springframework.security.config.web.server.SecurityWebFiltersOrder; 32 | import org.springframework.security.config.web.server.ServerHttpSecurity; 33 | import org.springframework.security.core.Authentication; 34 | import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; 35 | import org.springframework.security.core.userdetails.User; 36 | import org.springframework.security.core.userdetails.UserDetails; 37 | import org.springframework.security.web.server.SecurityWebFilterChain; 38 | import org.springframework.security.web.server.authentication.AuthenticationWebFilter; 39 | import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; 40 | import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; 41 | import org.springframework.web.server.ServerWebExchange; 42 | import reactor.core.publisher.Mono; 43 | 44 | import java.util.function.Function; 45 | 46 | /** 47 | * A Spring RESTful Application showing authentication and authorization 48 | * Signed Commit 49 | * @author rafa 50 | * 51 | **/ 52 | @SpringBootApplication 53 | @EnableWebFluxSecurity 54 | public class SecuredRestApplication { 55 | 56 | /** 57 | * Main entry point, built on top of Spring Boot it will point the begin of 58 | * execution. 59 | * 60 | * @param args Regular command line arguments can be added and their treatment 61 | * may be required 62 | */ 63 | public static void main(String[] args) { 64 | SpringApplication.run(SecuredRestApplication.class, args); 65 | } 66 | 67 | /** 68 | * A custom UserDetailsService to provide quick user rights for Spring Security, 69 | * more formal implementations may be added as separated files and annotated as 70 | * a Spring stereotype. 71 | * 72 | * @return MapReactiveUserDetailsService an InMemory implementation of user details 73 | */ 74 | @Bean 75 | public MapReactiveUserDetailsService userDetailsRepository() { 76 | UserDetails user = User.withDefaultPasswordEncoder() 77 | .username("user") 78 | .password("user") 79 | .roles("USER", "ADMIN") 80 | .build(); 81 | return new MapReactiveUserDetailsService(user); 82 | } 83 | 84 | /** 85 | * For Spring Security webflux, a chain of filters will provide user authentication 86 | * and authorization, we add custom filters to enable JWT token approach. 87 | * 88 | * @param http An initial object to build common filter scenarios. 89 | * Customized filters are added here. 90 | * @return SecurityWebFilterChain A filter chain for web exchanges that will 91 | * provide security 92 | **/ 93 | @Bean 94 | public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { 95 | 96 | http 97 | .authorizeExchange() 98 | .pathMatchers("/login", "/") 99 | .authenticated() 100 | .and() 101 | .addFilterAt(basicAuthenticationFilter(), SecurityWebFiltersOrder.HTTP_BASIC) 102 | .authorizeExchange() 103 | .pathMatchers("/api/**") 104 | .authenticated() 105 | .and() 106 | .addFilterAt(bearerAuthenticationFilter(), SecurityWebFiltersOrder.AUTHENTICATION); 107 | 108 | return http.build(); 109 | } 110 | 111 | /** 112 | * Use the already implemented logic in AuthenticationWebFilter and set a custom 113 | * SuccessHandler that will return a JWT when a user is authenticated with user/password 114 | * Create an AuthenticationManager using the UserDetailsService defined above 115 | * 116 | * @return AuthenticationWebFilter 117 | */ 118 | private AuthenticationWebFilter basicAuthenticationFilter(){ 119 | UserDetailsRepositoryReactiveAuthenticationManager authManager; 120 | AuthenticationWebFilter basicAuthenticationFilter; 121 | ServerAuthenticationSuccessHandler successHandler; 122 | 123 | authManager = new UserDetailsRepositoryReactiveAuthenticationManager(userDetailsRepository()); 124 | successHandler = new BasicAuthenticationSuccessHandler(); 125 | 126 | basicAuthenticationFilter = new AuthenticationWebFilter(authManager); 127 | basicAuthenticationFilter.setAuthenticationSuccessHandler(successHandler); 128 | 129 | return basicAuthenticationFilter; 130 | 131 | } 132 | 133 | /** 134 | * Use the already implemented logic by AuthenticationWebFilter and set a custom 135 | * converter that will handle requests containing a Bearer token inside 136 | * the HTTP Authorization header. 137 | * Set a dummy authentication manager to this filter, it's not needed because 138 | * the converter handles this. 139 | * 140 | * @return bearerAuthenticationFilter that will authorize requests containing a JWT 141 | */ 142 | private AuthenticationWebFilter bearerAuthenticationFilter(){ 143 | AuthenticationWebFilter bearerAuthenticationFilter; 144 | Function> bearerConverter; 145 | ReactiveAuthenticationManager authManager; 146 | 147 | authManager = new BearerTokenReactiveAuthenticationManager(); 148 | bearerAuthenticationFilter = new AuthenticationWebFilter(authManager); 149 | bearerConverter = new ServerHttpBearerAuthenticationConverter(); 150 | 151 | bearerAuthenticationFilter.setAuthenticationConverter(bearerConverter); 152 | bearerAuthenticationFilter.setRequiresAuthenticationMatcher(ServerWebExchangeMatchers.pathMatchers("/api/**")); 153 | 154 | return bearerAuthenticationFilter; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/main/java/io/rapha/spring/reactive/security/api/MessageController.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Permission is hereby granted, free of charge, to any person obtaining a 3 | * copy of this software and associated documentation files (the "Software"), 4 | * to deal in the Software without restriction, including without limitation 5 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, 6 | * and/or sell copies of the Software, and to permit persons to whom the 7 | * Software is furnished to do so, subject to the following conditions: 8 | * 9 | * The above copyright notice and this permission notice shall be included 10 | * in all copies or substantial portions of the Software. 11 | * 12 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL 15 | * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | * SOFTWARE. 19 | */ 20 | package io.rapha.spring.reactive.security.api; 21 | 22 | import io.rapha.spring.reactive.security.domain.FormattedMessage; 23 | import io.rapha.spring.reactive.security.service.MessageService; 24 | import org.springframework.beans.factory.annotation.Autowired; 25 | import org.springframework.security.access.prepost.PreAuthorize; 26 | import org.springframework.web.bind.annotation.GetMapping; 27 | import org.springframework.web.bind.annotation.RestController; 28 | import reactor.core.publisher.Flux; 29 | 30 | /** 31 | * A controller serving rest endpoints to show authorization features in this project 32 | * Endpoints for authentication are open, and others require the authenticated user to 33 | * have certain roles 34 | * 35 | * @author rafa 36 | */ 37 | @RestController 38 | public class MessageController { 39 | 40 | @Autowired 41 | MessageService messageService; 42 | 43 | /** 44 | * Root endpoint serves as a resource for Basic Authentication 45 | * 46 | * @return A publisher that serves a welcoming message 47 | */ 48 | @GetMapping("/") 49 | public Flux hello() { 50 | return messageService.getDefaultMessage(); 51 | } 52 | 53 | /** 54 | * Common login endpoint is also available for basic authentication 55 | * 56 | * @return A publisher serving a message stating successful log in 57 | */ 58 | @GetMapping("/login") 59 | public Flux login() { 60 | return messageService.getDefaultMessage(); 61 | } 62 | 63 | /** 64 | * A restricted endpoint requiring consumers to be authenticated and also 65 | * have the right roles for this resource 66 | * 67 | * @return A publisher serving a message when access is granted 68 | */ 69 | @GetMapping("/api/private") 70 | @PreAuthorize("hasRole('USER')") 71 | public Flux privateMessage() { 72 | return messageService.getCustomMessage("User"); 73 | } 74 | 75 | /** 76 | * A restricted endpoint requiring consumers to be authenticated and also 77 | * have the right roles for this resource 78 | * 79 | * @return A publisher serving a message when access is granted 80 | */ 81 | @GetMapping("/api/admin") 82 | @PreAuthorize("hasRole('ADMIN')") 83 | public Flux privateMessageAdmin() { 84 | return messageService.getCustomMessage("Admin"); 85 | } 86 | 87 | /** 88 | * A restricted endpoint requiring consumers to be authenticated and also 89 | * have the right roles for this resource 90 | * 91 | * @return A publisher serving a message when access is granted 92 | */ 93 | @GetMapping("/api/guest") 94 | @PreAuthorize("hasRole('GUEST')") 95 | public Flux privateMessageGuest() { 96 | return messageService.getCustomMessage("Guest"); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/main/java/io/rapha/spring/reactive/security/auth/basic/BasicAuthenticationSuccessHandler.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Permission is hereby granted, free of charge, to any person obtaining a 3 | * copy of this software and associated documentation files (the "Software"), 4 | * to deal in the Software without restriction, including without limitation 5 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, 6 | * and/or sell copies of the Software, and to permit persons to whom the 7 | * Software is furnished to do so, subject to the following conditions: 8 | * 9 | * The above copyright notice and this permission notice shall be included 10 | * in all copies or substantial portions of the Software. 11 | * 12 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL 15 | * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | * SOFTWARE. 19 | */ 20 | package io.rapha.spring.reactive.security.auth.basic; 21 | 22 | import io.rapha.spring.reactive.security.auth.jwt.JWTTokenService; 23 | import org.springframework.http.HttpHeaders; 24 | import org.springframework.security.core.Authentication; 25 | import org.springframework.security.web.server.WebFilterExchange; 26 | import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; 27 | import org.springframework.stereotype.Component; 28 | import org.springframework.web.server.ServerWebExchange; 29 | import reactor.core.publisher.Mono; 30 | 31 | /** 32 | * On success authentication a signed JWT object is serialized and added 33 | * in the authorization header as a bearer token 34 | */ 35 | @Component 36 | public class BasicAuthenticationSuccessHandler 37 | implements ServerAuthenticationSuccessHandler { 38 | 39 | /** 40 | * A successful authentication object us used to create a JWT object and 41 | * added in the authorization header of the current WebExchange 42 | * 43 | * @param webFilterExchange 44 | * @param authentication 45 | * @return 46 | */ 47 | @Override 48 | public Mono onAuthenticationSuccess(WebFilterExchange webFilterExchange, Authentication authentication) { 49 | ServerWebExchange exchange = webFilterExchange.getExchange(); 50 | //TODO refactor this nasty implementation 51 | exchange.getResponse() 52 | .getHeaders() 53 | .add(HttpHeaders.AUTHORIZATION, getHttpAuthHeaderValue(authentication)); 54 | return webFilterExchange.getChain().filter(exchange); 55 | } 56 | 57 | private static String getHttpAuthHeaderValue(Authentication authentication){ 58 | return String.join(" ","Bearer",tokenFromAuthentication(authentication)); 59 | } 60 | 61 | private static String tokenFromAuthentication(Authentication authentication){ 62 | return JWTTokenService.generateToken( 63 | authentication.getName(), 64 | authentication.getCredentials(), 65 | authentication.getAuthorities()); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/main/java/io/rapha/spring/reactive/security/auth/bearer/BearerTokenReactiveAuthenticationManager.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Permission is hereby granted, free of charge, to any person obtaining a 3 | * copy of this software and associated documentation files (the "Software"), 4 | * to deal in the Software without restriction, including without limitation 5 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, 6 | * and/or sell copies of the Software, and to permit persons to whom the 7 | * Software is furnished to do so, subject to the following conditions: 8 | * 9 | * The above copyright notice and this permission notice shall be included 10 | * in all copies or substantial portions of the Software. 11 | * 12 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL 15 | * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | * SOFTWARE. 19 | */ 20 | package io.rapha.spring.reactive.security.auth.bearer; 21 | 22 | import org.springframework.security.authentication.ReactiveAuthenticationManager; 23 | import org.springframework.security.core.Authentication; 24 | import reactor.core.publisher.Mono; 25 | 26 | /** 27 | * An authentication manager intended to authenticate a JWT exchange 28 | * JWT tokens contain all information within the token itself 29 | * so an authentication manager is not necessary but we provide this 30 | * implementation to follow a standard. 31 | * Invalid tokens are filtered one previous step 32 | */ 33 | public class BearerTokenReactiveAuthenticationManager implements ReactiveAuthenticationManager { 34 | 35 | /** 36 | * Successfully authenticate an Authentication object 37 | * 38 | * @param authentication A valid authentication object 39 | * @return authentication A valid authentication object 40 | */ 41 | @Override 42 | public Mono authenticate(Authentication authentication) { 43 | return Mono.just(authentication); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/io/rapha/spring/reactive/security/auth/bearer/ServerHttpBearerAuthenticationConverter.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Permission is hereby granted, free of charge, to any person obtaining a 3 | * copy of this software and associated documentation files (the "Software"), 4 | * to deal in the Software without restriction, including without limitation 5 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, 6 | * and/or sell copies of the Software, and to permit persons to whom the 7 | * Software is furnished to do so, subject to the following conditions: 8 | * 9 | * The above copyright notice and this permission notice shall be included 10 | * in all copies or substantial portions of the Software. 11 | * 12 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL 15 | * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | * SOFTWARE. 19 | */ 20 | package io.rapha.spring.reactive.security.auth.bearer; 21 | 22 | import io.rapha.spring.reactive.security.auth.jwt.AuthorizationHeaderPayload; 23 | import io.rapha.spring.reactive.security.auth.jwt.UsernamePasswordAuthenticationBearer; 24 | import io.rapha.spring.reactive.security.auth.jwt.JWTCustomVerifier; 25 | import org.springframework.security.core.Authentication; 26 | import org.springframework.web.server.ServerWebExchange; 27 | import reactor.core.publisher.Mono; 28 | 29 | import java.util.function.Function; 30 | import java.util.function.Predicate; 31 | 32 | /** 33 | * This converter extracts a bearer token from a WebExchange and 34 | * returns an Authentication object if the JWT token is valid. 35 | * Validity means is well formed and signature is correct 36 | */ 37 | public class ServerHttpBearerAuthenticationConverter implements Function> { 38 | 39 | private static final String BEARER = "Bearer "; 40 | private static final Predicate matchBearerLength = authValue -> authValue.length() > BEARER.length(); 41 | private static final Function> isolateBearerValue = authValue -> Mono.justOrEmpty(authValue.substring(BEARER.length())); 42 | 43 | private JWTCustomVerifier jwtVerifier = new JWTCustomVerifier(); 44 | /** 45 | * Apply this function to the current WebExchange, an Authentication object 46 | * is returned when completed. 47 | * 48 | * @param serverWebExchange 49 | * @return 50 | */ 51 | @Override 52 | public Mono apply(ServerWebExchange serverWebExchange) { 53 | return Mono.justOrEmpty(serverWebExchange) 54 | .flatMap(AuthorizationHeaderPayload::extract) 55 | .filter(matchBearerLength) 56 | .flatMap(isolateBearerValue) 57 | .flatMap(jwtVerifier::check) 58 | .flatMap(UsernamePasswordAuthenticationBearer::create).log(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/java/io/rapha/spring/reactive/security/auth/jwt/AuthorizationHeaderPayload.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Permission is hereby granted, free of charge, to any person obtaining a 3 | * copy of this software and associated documentation files (the "Software"), 4 | * to deal in the Software without restriction, including without limitation 5 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, 6 | * and/or sell copies of the Software, and to permit persons to whom the 7 | * Software is furnished to do so, subject to the following conditions: 8 | * 9 | * The above copyright notice and this permission notice shall be included 10 | * in all copies or substantial portions of the Software. 11 | * 12 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL 15 | * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | * SOFTWARE. 19 | */ 20 | package io.rapha.spring.reactive.security.auth.jwt; 21 | 22 | import org.springframework.http.HttpHeaders; 23 | import org.springframework.web.server.ServerWebExchange; 24 | import reactor.core.publisher.Mono; 25 | 26 | public class AuthorizationHeaderPayload { 27 | 28 | public static Mono extract(ServerWebExchange serverWebExchange) { 29 | return Mono.justOrEmpty(serverWebExchange.getRequest() 30 | .getHeaders() 31 | .getFirst(HttpHeaders.AUTHORIZATION)); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/io/rapha/spring/reactive/security/auth/jwt/JWTCustomSigner.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Permission is hereby granted, free of charge, to any person obtaining a 3 | * copy of this software and associated documentation files (the "Software"), 4 | * to deal in the Software without restriction, including without limitation 5 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, 6 | * and/or sell copies of the Software, and to permit persons to whom the 7 | * Software is furnished to do so, subject to the following conditions: 8 | * 9 | * The above copyright notice and this permission notice shall be included 10 | * in all copies or substantial portions of the Software. 11 | * 12 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL 15 | * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | * SOFTWARE. 19 | */ 20 | package io.rapha.spring.reactive.security.auth.jwt; 21 | 22 | import com.nimbusds.jose.JWSSigner; 23 | import com.nimbusds.jose.KeyLengthException; 24 | import com.nimbusds.jose.crypto.MACSigner; 25 | 26 | /** 27 | * Creates a JWTSigner using a simple secret string 28 | */ 29 | public class JWTCustomSigner { 30 | private JWSSigner signer; 31 | 32 | public JWTCustomSigner() { 33 | try { 34 | this.signer = new MACSigner(JWTSecrets.DEFAULT_SECRET); 35 | } catch (KeyLengthException e) { 36 | this.signer = null; 37 | } 38 | } 39 | 40 | public JWSSigner getSigner() { 41 | return this.signer; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/io/rapha/spring/reactive/security/auth/jwt/JWTCustomVerifier.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Permission is hereby granted, free of charge, to any person obtaining a 3 | * copy of this software and associated documentation files (the "Software"), 4 | * to deal in the Software without restriction, including without limitation 5 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, 6 | * and/or sell copies of the Software, and to permit persons to whom the 7 | * Software is furnished to do so, subject to the following conditions: 8 | * 9 | * The above copyright notice and this permission notice shall be included 10 | * in all copies or substantial portions of the Software. 11 | * 12 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL 15 | * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | * SOFTWARE. 19 | */ 20 | package io.rapha.spring.reactive.security.auth.jwt; 21 | 22 | import com.nimbusds.jose.JOSEException; 23 | import com.nimbusds.jose.JWSVerifier; 24 | import com.nimbusds.jose.crypto.MACVerifier; 25 | import com.nimbusds.jwt.SignedJWT; 26 | import reactor.core.publisher.Mono; 27 | 28 | import java.text.ParseException; 29 | import java.time.Instant; 30 | import java.util.Date; 31 | import java.util.function.Predicate; 32 | 33 | /** 34 | * Decides when a JWT string is valid. 35 | * First try to parse it, then check that 36 | * the signature is correct. 37 | * If something fails an empty Mono is returning 38 | * meaning that is not valid. 39 | * Verify that expiration date is valid 40 | */ 41 | public class JWTCustomVerifier { 42 | private JWSVerifier jwsVerifier; 43 | 44 | public JWTCustomVerifier() { 45 | this.jwsVerifier = this.buildJWSVerifier(); 46 | } 47 | 48 | public Mono check(String token) { 49 | return Mono.justOrEmpty(createJWS(token)) 50 | .filter(isNotExpired) 51 | .filter(validSignature); 52 | } 53 | 54 | private Predicate isNotExpired = token -> 55 | getExpirationDate(token).after(Date.from(Instant.now())); 56 | 57 | private Predicate validSignature = token -> { 58 | try { 59 | return token.verify(this.jwsVerifier); 60 | } catch (JOSEException e) { 61 | e.printStackTrace(); 62 | return false; 63 | } 64 | }; 65 | 66 | private MACVerifier buildJWSVerifier() { 67 | try { 68 | return new MACVerifier(JWTSecrets.DEFAULT_SECRET); 69 | } catch (JOSEException e) { 70 | e.printStackTrace(); 71 | return null; 72 | } 73 | } 74 | 75 | private SignedJWT createJWS(String token) { 76 | try { 77 | return SignedJWT.parse(token); 78 | } catch (ParseException e) { 79 | e.printStackTrace(); 80 | return null; 81 | } 82 | } 83 | 84 | private Date getExpirationDate(SignedJWT token) { 85 | try { 86 | return token.getJWTClaimsSet() 87 | .getExpirationTime(); 88 | } catch (ParseException e) { 89 | e.printStackTrace(); 90 | return null; 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/main/java/io/rapha/spring/reactive/security/auth/jwt/JWTSecrets.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Permission is hereby granted, free of charge, to any person obtaining a 3 | * copy of this software and associated documentation files (the "Software"), 4 | * to deal in the Software without restriction, including without limitation 5 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, 6 | * and/or sell copies of the Software, and to permit persons to whom the 7 | * Software is furnished to do so, subject to the following conditions: 8 | * 9 | * The above copyright notice and this permission notice shall be included 10 | * in all copies or substantial portions of the Software. 11 | * 12 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL 15 | * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | * SOFTWARE. 19 | */ 20 | package io.rapha.spring.reactive.security.auth.jwt; 21 | 22 | /** 23 | * A a static class that abstracts a secret provider 24 | * Later this one can be changed with a better approach 25 | * 26 | */ 27 | public class JWTSecrets { 28 | 29 | /** 30 | * A default secret for development purposes. 31 | */ 32 | public final static String DEFAULT_SECRET = "qwertyuiopasdfghjklzxcvbnmqwerty"; 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/io/rapha/spring/reactive/security/auth/jwt/JWTTokenService.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Permission is hereby granted, free of charge, to any person obtaining a 3 | * copy of this software and associated documentation files (the "Software"), 4 | * to deal in the Software without restriction, including without limitation 5 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, 6 | * and/or sell copies of the Software, and to permit persons to whom the 7 | * Software is furnished to do so, subject to the following conditions: 8 | * 9 | * The above copyright notice and this permission notice shall be included 10 | * in all copies or substantial portions of the Software. 11 | * 12 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL 15 | * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | * SOFTWARE. 19 | */ 20 | package io.rapha.spring.reactive.security.auth.jwt; 21 | 22 | import com.nimbusds.jose.JOSEException; 23 | import com.nimbusds.jose.JWSAlgorithm; 24 | import com.nimbusds.jose.JWSHeader; 25 | import com.nimbusds.jwt.JWTClaimsSet; 26 | import com.nimbusds.jwt.SignedJWT; 27 | import org.springframework.security.core.GrantedAuthority; 28 | 29 | import java.time.Period; 30 | import java.util.Collection; 31 | import java.util.Date; 32 | import java.util.stream.Collectors; 33 | 34 | /** 35 | * A service to create JWT objects, this one is used when an exchange 36 | * provides basic authentication. 37 | * If authentication is successful, a token is added in the response 38 | */ 39 | public class JWTTokenService { 40 | 41 | /** 42 | * Create and sign a JWT object using information from the current 43 | * authenticated principal 44 | * 45 | * @param subject Name of current principal 46 | * @param credentials Credentials of current principal 47 | * @param authorities A collection of granted authorities for this principal 48 | * @return String representing a valid token 49 | */ 50 | public static String generateToken(String subject, Object credentials, Collection authorities) { 51 | SignedJWT signedJWT; 52 | JWTClaimsSet claimsSet; 53 | 54 | //TODO refactor this nasty code 55 | 56 | claimsSet = new JWTClaimsSet.Builder() 57 | .subject(subject) 58 | .issuer("rapha.io") 59 | .expirationTime(new Date(getExpiration())) 60 | .claim("roles", authorities 61 | .stream() 62 | .map(GrantedAuthority.class::cast) 63 | .map(GrantedAuthority::getAuthority) 64 | .collect(Collectors.joining(","))) 65 | .build(); 66 | 67 | signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claimsSet); 68 | 69 | try { 70 | signedJWT.sign(new JWTCustomSigner().getSigner()); 71 | } catch (JOSEException e) { 72 | e.printStackTrace(); 73 | } 74 | 75 | return signedJWT.serialize(); 76 | } 77 | 78 | /** 79 | * Returns a millisecond time representation 24hrs from now 80 | * to be used as the time the currently token will be valid 81 | * 82 | * @return Time representation 24 from now 83 | */ 84 | private static long getExpiration(){ 85 | return new Date().toInstant() 86 | .plus(Period.ofDays(1)) 87 | .toEpochMilli(); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/main/java/io/rapha/spring/reactive/security/auth/jwt/UsernamePasswordAuthenticationBearer.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Permission is hereby granted, free of charge, to any person obtaining a 3 | * copy of this software and associated documentation files (the "Software"), 4 | * to deal in the Software without restriction, including without limitation 5 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, 6 | * and/or sell copies of the Software, and to permit persons to whom the 7 | * Software is furnished to do so, subject to the following conditions: 8 | * 9 | * The above copyright notice and this permission notice shall be included 10 | * in all copies or substantial portions of the Software. 11 | * 12 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL 15 | * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | * SOFTWARE. 19 | */ 20 | package io.rapha.spring.reactive.security.auth.jwt; 21 | 22 | import com.nimbusds.jwt.SignedJWT; 23 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 24 | import org.springframework.security.core.Authentication; 25 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 26 | import reactor.core.publisher.Mono; 27 | 28 | import java.text.ParseException; 29 | import java.util.List; 30 | import java.util.stream.Collectors; 31 | import java.util.stream.Stream; 32 | 33 | /** 34 | * This converter takes a SignedJWT and extracts all information 35 | * contained to build an Authentication Object 36 | * The signed JWT has already been verified. 37 | * 38 | */ 39 | public class UsernamePasswordAuthenticationBearer { 40 | 41 | public static Mono create(SignedJWT signedJWTMono) { 42 | SignedJWT signedJWT = signedJWTMono; 43 | String subject; 44 | String auths; 45 | List authorities; 46 | 47 | try { 48 | subject = signedJWT.getJWTClaimsSet().getSubject(); 49 | auths = (String) signedJWT.getJWTClaimsSet().getClaim("roles"); 50 | } catch (ParseException e) { 51 | return Mono.empty(); 52 | } 53 | authorities = Stream.of(auths.split(",")) 54 | .map(a -> new SimpleGrantedAuthority(a)) 55 | .collect(Collectors.toList()); 56 | 57 | return Mono.justOrEmpty(new UsernamePasswordAuthenticationToken(subject, null, authorities)); 58 | 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/java/io/rapha/spring/reactive/security/domain/FormattedMessage.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Permission is hereby granted, free of charge, to any person obtaining a 3 | * copy of this software and associated documentation files (the "Software"), 4 | * to deal in the Software without restriction, including without limitation 5 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, 6 | * and/or sell copies of the Software, and to permit persons to whom the 7 | * Software is furnished to do so, subject to the following conditions: 8 | * 9 | * The above copyright notice and this permission notice shall be included 10 | * in all copies or substantial portions of the Software. 11 | * 12 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL 15 | * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | * SOFTWARE. 19 | */ 20 | package io.rapha.spring.reactive.security.domain; 21 | 22 | public class FormattedMessage { 23 | 24 | private String name; 25 | private String message; 26 | 27 | public FormattedMessage(){ 28 | this.name = "Default"; 29 | this.message = "Hello World!"; 30 | } 31 | 32 | public FormattedMessage(String name){ 33 | this.name = name; 34 | this.message = "Hello "+this.name+"!"; 35 | } 36 | 37 | public String getMessage() { 38 | return message; 39 | } 40 | 41 | public void setMessage(String message) { 42 | this.message = message; 43 | } 44 | 45 | public String getName() { 46 | return name; 47 | } 48 | 49 | public void setName(String name) { 50 | this.name = name; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/java/io/rapha/spring/reactive/security/service/MessageService.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Permission is hereby granted, free of charge, to any person obtaining a 3 | * copy of this software and associated documentation files (the "Software"), 4 | * to deal in the Software without restriction, including without limitation 5 | * the rights to use, copy, modify, merge, publish, distribute, sublicense, 6 | * and/or sell copies of the Software, and to permit persons to whom the 7 | * Software is furnished to do so, subject to the following conditions: 8 | * 9 | * The above copyright notice and this permission notice shall be included 10 | * in all copies or substantial portions of the Software. 11 | * 12 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL 15 | * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | * SOFTWARE. 19 | */ 20 | package io.rapha.spring.reactive.security.service; 21 | 22 | import io.rapha.spring.reactive.security.domain.FormattedMessage; 23 | import org.springframework.stereotype.Component; 24 | import reactor.core.publisher.Flux; 25 | 26 | @Component 27 | public class MessageService { 28 | 29 | public Flux getDefaultMessage(){ 30 | return Flux.just(new FormattedMessage()); 31 | } 32 | 33 | public Flux getCustomMessage(String name){ 34 | return Flux.just(new FormattedMessage(name)); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/test/java/io/rapha/spring/reactive/security/SecuredRestApplicationTest.java: -------------------------------------------------------------------------------- 1 | package io.rapha.spring.reactive.security; 2 | 3 | 4 | import org.junit.Test; 5 | import org.junit.runner.RunWith; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.test.context.SpringBootTest; 8 | import org.springframework.context.ApplicationContext; 9 | import org.springframework.test.context.junit4.SpringRunner; 10 | import org.springframework.test.web.reactive.server.WebTestClient; 11 | 12 | @RunWith(SpringRunner.class) 13 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 14 | public class SecuredRestApplicationTest { 15 | 16 | @Autowired 17 | private ApplicationContext context; 18 | 19 | @Autowired 20 | private WebTestClient rest; 21 | 22 | @Test 23 | public void messageWhenNotAuthenticated() { 24 | this.rest 25 | .get() 26 | .uri("/api/admin") 27 | .exchange() 28 | .expectStatus().isUnauthorized(); 29 | } 30 | } --------------------------------------------------------------------------------