├── .gitignore ├── LICENSE.txt ├── README.md ├── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── lombok.config ├── settings.gradle └── src ├── main ├── docker │ └── docker-compose.yml ├── java │ └── com │ │ └── github │ │ └── eriknyk │ │ └── webfluxjwtsecurity │ │ ├── WebfluxSecurityApplication.java │ │ ├── app │ │ ├── ApiException.java │ │ ├── AppErrorAttributes.java │ │ └── AppErrorWebExceptionHandler.java │ │ ├── configuration │ │ ├── AppConfig.java │ │ └── security │ │ │ ├── AuthenticationManager.java │ │ │ ├── WebSecurityConfig.java │ │ │ ├── auth │ │ │ ├── CurrentUserAuthenticationBearer.java │ │ │ ├── LocalUserDetails.java │ │ │ ├── UnauthorizedException.java │ │ │ └── UserPrincipal.java │ │ │ └── support │ │ │ ├── JwtVerifyHandler.java │ │ │ ├── PBKDF2Encoder.java │ │ │ ├── ServerHttpBearerAuthenticationConverter.java │ │ │ └── ServerHttpCookieAuthenticationConverter.java │ │ ├── controller │ │ ├── AuthController.java │ │ ├── PublicController.java │ │ └── UserController.java │ │ ├── dto │ │ ├── AuthResultDto.java │ │ ├── UserDto.java │ │ ├── UserLoginDto.java │ │ └── mapper │ │ │ └── UserMapper.java │ │ ├── model │ │ ├── auth │ │ │ └── TokenInfo.java │ │ └── user │ │ │ ├── User.java │ │ │ └── UserRepository.java │ │ └── service │ │ ├── UserService.java │ │ └── security │ │ ├── AuthException.java │ │ └── SecurityService.java └── resources │ ├── application.yaml │ └── schema │ └── database.sql └── test └── java └── com └── github └── eriknyk └── webfluxjwtsecurity └── WebfluxSecurityApplicationTests.java /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | bin/ 17 | !**/src/main/**/bin/ 18 | !**/src/test/**/bin/ 19 | 20 | ### IntelliJ IDEA ### 21 | .idea 22 | *.iws 23 | *.iml 24 | *.ipr 25 | out/ 26 | !**/src/main/**/out/ 27 | !**/src/test/**/out/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | ### VS Code ### 37 | .vscode/ 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2021 Erik Amaru Ortiz 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation 4 | files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, 5 | modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software 6 | is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 11 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 13 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Spring Webflux JWT Security Demo 2 | ================================ 3 | 4 | The motivation for this demo, is just because it is very difficult to find a complete implementation 5 | of spring webflux + security + jwt + r2db all in one functional implementation, all that you can find in internet are incomplete, not functional 6 | or very older examples, I didn't find any complete example like this from official spring examples neither. 7 | That's why I did it and wanted to share it to anybody that is needing it. 8 | 9 | Any improvement, fix, contribution are welcome. 10 | 11 | Happy codding!! 12 | 13 | ## What this demo has? 14 | - Spring webflux 15 | - Spring security implemented with JWT + validation layer 16 | - User register demo endpoint 17 | - User authentication endpoint 18 | - Model to dto mapping (using mapstruct) 19 | - User R2db with Postgresql repository impl 20 | - User validation in spring security layer, according to the user record in db 21 | 22 | ## Data base setup 23 | - If you don't have installed Postgresql locally you can just run it with docker 24 | 25 | ```bash 26 | docker-compose -f src/main/docker/docker-compose.yml up -d 27 | ``` 28 | 29 | ## Create db and Users table 30 | just execute the sql script located in `src/resources/schema/database.sql` or copy teh following sentences: 31 | 32 | ```sql 33 | CREATE DATABASE "webflux-security"; 34 | 35 | CREATE TABLE users 36 | ( 37 | id SERIAL PRIMARY KEY, 38 | username VARCHAR(64), 39 | password VARCHAR(64), 40 | roles TEXT[], 41 | first_name VARCHAR(64), 42 | last_name VARCHAR(64), 43 | enabled BOOLEAN, 44 | created_at TIMESTAMP, 45 | updated_at TIMESTAMP 46 | ); 47 | ``` 48 | 49 | 50 | ## Create demo user 51 | 52 | ```bash 53 | curl http://localhost:9000/public/demo-user \ 54 | -X POST \ 55 | -H 'Content-Type: application/json' \ 56 | -d '{ 57 | "username": "admin", 58 | "password": "admin", 59 | "first_name": "John", 60 | "last_name": "Doe" 61 | }' 62 | ``` 63 | 64 | ## Authenticate and get a valid JWT token 65 | ```bash 66 | curl http://localhost:9000/login \ 67 | -X POST \ 68 | -H 'Content-Type: application/json' \ 69 | -d '{ 70 | "username": "admin", 71 | "password": "admin" 72 | }' | json_pp 73 | ``` 74 | 75 | API Response 76 | ```json 77 | { 78 | "issuedAt" : "2021-04-09T18:48:04.052+00:00", 79 | "userId" : 1, 80 | "expiresAt" : "2021-04-10T02:48:04.052+00:00", 81 | "token" : "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwicm9sZSI6WyJST0xFX1VTRVIiXSwiaXNzIjoiYWRtaW4iLCJleHAiOjE2MTgwMjI4ODQsImlhdCI6MTYxNzk5NDA4NCwianRpIjoiODUzNTAwNDUtYjNjNy00MTA3LWIyZjUtOGEwNDUyNjVmZWM5In0.okhxY7BsK3S3ABNMJlm1WhGdjssy676d6bNkZ3ybN34" 82 | } 83 | ``` 84 | 85 | ## Make an authenticated request 86 | (!) Use jwt token obtained previously 87 | 88 | ```bash 89 | curl http://localhost:9000/user \ 90 | -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwicm9sZSI6WyJST0xFX1VTRVIiXSwiaXNzIjoiYWRtaW4iLCJleHAiOjE2MTgwMjI4ODQsImlhdCI6MTYxNzk5NDA4NCwianRpIjoiODUzNTAwNDUtYjNjNy00MTA3LWIyZjUtOGEwNDUyNjVmZWM5In0.okhxY7BsK3S3ABNMJlm1WhGdjssy676d6bNkZ3ybN34' | json_pp 91 | ``` 92 | 93 | API Response 94 | ```json 95 | { 96 | "enabled" : true, 97 | "id" : 1, 98 | "first_name" : "John", 99 | "username" : "admin", 100 | "last_name" : "Doe" 101 | } 102 | ``` 103 | 104 | # Last notes 105 | - The default JWT token expiration is 28800 seconds = 8 hours, you can configure this and other jwt params in `src/resources/application.yml` 106 | - If you update the user record in db, updating the enabled column to false, and try to fetch `GET /user` once again api will return an error 401 107 | 108 | # License 109 | MIT -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'org.springframework.boot' version '2.5.0-SNAPSHOT' 3 | id 'io.spring.dependency-management' version '1.0.11.RELEASE' 4 | id 'java' 5 | } 6 | 7 | group = 'com.github.eriknyk' 8 | version = '0.0.1-SNAPSHOT' 9 | sourceCompatibility = '15' 10 | 11 | repositories { 12 | mavenCentral() 13 | maven { url 'https://repo.spring.io/milestone' } 14 | maven { url 'https://repo.spring.io/snapshot' } 15 | } 16 | 17 | dependencies { 18 | annotationProcessor('org.projectlombok:lombok') 19 | compileOnly('org.projectlombok:lombok') 20 | 21 | implementation('org.springframework.boot:spring-boot-starter-webflux') 22 | implementation('org.springframework.boot:spring-boot-starter-security') 23 | implementation('org.springframework.session:spring-session-core') 24 | implementation('org.springframework.boot:spring-boot-starter-data-r2dbc') 25 | 26 | // postgres support 27 | implementation('io.r2dbc:r2dbc-pool:0.8.6.RELEASE') 28 | runtimeOnly('io.r2dbc:r2dbc-postgresql') 29 | runtimeOnly('org.postgresql:postgresql') 30 | 31 | // Mapping 32 | implementation('org.mapstruct:mapstruct:1.4.2.Final') 33 | annotationProcessor('org.mapstruct:mapstruct-processor:1.4.2.Final') 34 | 35 | // JWT 36 | implementation('io.jsonwebtoken:jjwt:0.9.1') 37 | implementation('javax.xml.bind:jaxb-api:2.3.1') // required by jjwt 38 | 39 | testImplementation 'org.springframework.boot:spring-boot-starter-test' 40 | } 41 | 42 | test { 43 | useJUnitPlatform() 44 | } 45 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eriknyk/webflux-jwt-security-demo/b4f944d953453488bf27682600fa466647c34a42/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-6.8.3-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /lombok.config: -------------------------------------------------------------------------------- 1 | config.stopBubbling = true 2 | 3 | lombok.accessors.fluent = false 4 | lombok.addNullAnnotations = spring -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | maven { url 'https://repo.spring.io/milestone' } 4 | maven { url 'https://repo.spring.io/snapshot' } 5 | gradlePluginPortal() 6 | } 7 | } 8 | rootProject.name = 'webflux-jwt-security' 9 | -------------------------------------------------------------------------------- /src/main/docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | postgres: 5 | image: "postgres:11" 6 | ports: 7 | - "5432:5432" 8 | environment: 9 | POSTGRES_USER: postgres 10 | POSTGRES_PASSWORD: postgres 11 | volumes: 12 | - pgdata:/var/lib/postgresql/data 13 | 14 | volumes: 15 | pgdata: 16 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/WebfluxSecurityApplication.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | /** 7 | * WebfluxSecurityApplication class 8 | * 9 | * @author Erik Amaru Ortiz 10 | */ 11 | @SpringBootApplication 12 | public class WebfluxSecurityApplication { 13 | public static void main(String[] args) { 14 | SpringApplication.run(WebfluxSecurityApplication.class, args); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/app/ApiException.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.app; 2 | 3 | import lombok.Getter; 4 | 5 | /** 6 | * ApiException class 7 | * 8 | * @author Erik Amaru Ortiz 9 | */ 10 | public class ApiException extends RuntimeException { 11 | @Getter 12 | protected String errorCode; 13 | 14 | public ApiException(String message, String errorCode) { 15 | super(message); 16 | this.errorCode = errorCode; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/app/AppErrorAttributes.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.app; 2 | 3 | import com.github.eriknyk.webfluxjwtsecurity.configuration.security.auth.UnauthorizedException; 4 | import com.github.eriknyk.webfluxjwtsecurity.service.security.AuthException; 5 | import io.jsonwebtoken.ExpiredJwtException; 6 | import io.jsonwebtoken.MalformedJwtException; 7 | import io.jsonwebtoken.SignatureException; 8 | import org.springframework.boot.web.error.ErrorAttributeOptions; 9 | import org.springframework.boot.web.reactive.error.DefaultErrorAttributes; 10 | import org.springframework.http.HttpStatus; 11 | import org.springframework.stereotype.Component; 12 | import org.springframework.web.reactive.function.server.ServerRequest; 13 | 14 | import java.util.ArrayList; 15 | import java.util.HashMap; 16 | import java.util.LinkedHashMap; 17 | import java.util.Map; 18 | 19 | /** 20 | * AppErrorAttributes class 21 | * 22 | * @author Erik Amaru Ortiz 23 | */ 24 | @Component 25 | public class AppErrorAttributes extends DefaultErrorAttributes { 26 | private HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR; 27 | 28 | public AppErrorAttributes() { 29 | super(); 30 | } 31 | 32 | @Override 33 | public Map getErrorAttributes(ServerRequest request, ErrorAttributeOptions options) { 34 | var errorAttributes = super.getErrorAttributes(request, ErrorAttributeOptions.defaults()); 35 | var error = getError(request); 36 | 37 | var errorList = new ArrayList>(); 38 | 39 | if (error instanceof AuthException || error instanceof UnauthorizedException) { 40 | status = HttpStatus.UNAUTHORIZED; 41 | var errorMap = new LinkedHashMap(); 42 | errorMap.put("code", ((ApiException) error).getErrorCode()); 43 | errorMap.put("message", error.getMessage()); 44 | errorList.add(errorMap); 45 | } else if (error instanceof ApiException) { 46 | status = HttpStatus.BAD_REQUEST; 47 | var errorMap = new LinkedHashMap(); 48 | errorMap.put("code", ((ApiException) error).getErrorCode()); 49 | errorMap.put("message", error.getMessage()); 50 | errorList.add(errorMap); 51 | } else if (error instanceof ExpiredJwtException || error instanceof SignatureException || error instanceof MalformedJwtException) { 52 | status = HttpStatus.UNAUTHORIZED; 53 | var errorMap = new LinkedHashMap(); 54 | errorMap.put("code", "UNAUTHORIZED"); 55 | errorMap.put("message", error.getMessage()); 56 | errorList.add(errorMap); 57 | } else { 58 | status = HttpStatus.INTERNAL_SERVER_ERROR; 59 | var message = error.getMessage(); 60 | if (message == null) 61 | message = error.getClass().getName(); 62 | 63 | var errorMap = new LinkedHashMap(); 64 | errorMap.put("code", "INTERNAL_ERROR"); 65 | errorMap.put("message", message); 66 | errorList.add(errorMap); 67 | } 68 | 69 | var errors = new HashMap(); 70 | errors.put("errors", errorList); 71 | errorAttributes.put("status", status.value()); 72 | errorAttributes.put("errors", errors); 73 | 74 | return errorAttributes; 75 | } 76 | 77 | /** 78 | * @return the status 79 | */ 80 | public HttpStatus getStatus() { 81 | return status; 82 | } 83 | 84 | /** 85 | * @param status the status to set 86 | */ 87 | public void setStatus(HttpStatus status) { 88 | this.status = status; 89 | } 90 | } 91 | 92 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/app/AppErrorWebExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.app; 2 | 3 | import org.springframework.boot.autoconfigure.web.WebProperties; 4 | import org.springframework.boot.autoconfigure.web.reactive.error.AbstractErrorWebExceptionHandler; 5 | import org.springframework.boot.web.error.ErrorAttributeOptions; 6 | import org.springframework.boot.web.reactive.error.ErrorAttributes; 7 | import org.springframework.context.ApplicationContext; 8 | import org.springframework.core.annotation.Order; 9 | import org.springframework.http.MediaType; 10 | import org.springframework.http.codec.ServerCodecConfigurer; 11 | import org.springframework.stereotype.Component; 12 | import org.springframework.web.reactive.function.BodyInserters; 13 | import org.springframework.web.reactive.function.server.*; 14 | 15 | /** 16 | * AppErrorWebExceptionHandler class 17 | * 18 | * @author Erik Amaru Ortiz 19 | */ 20 | @Component 21 | @Order(-2) 22 | public class AppErrorWebExceptionHandler extends AbstractErrorWebExceptionHandler { 23 | public AppErrorWebExceptionHandler(AppErrorAttributes g, ApplicationContext applicationContext, ServerCodecConfigurer serverCodecConfigurer) { 24 | super(g, new WebProperties.Resources(), applicationContext); 25 | super.setMessageWriters(serverCodecConfigurer.getWriters()); 26 | super.setMessageReaders(serverCodecConfigurer.getReaders()); 27 | } 28 | 29 | @Override 30 | protected RouterFunction getRoutingFunction(final ErrorAttributes errorAttributes) { 31 | return RouterFunctions.route(RequestPredicates.all(), request -> { 32 | var props = getErrorAttributes(request, ErrorAttributeOptions.defaults()); 33 | 34 | return ServerResponse.status(Integer.parseInt(props.getOrDefault("status", 500).toString())) 35 | .contentType(MediaType.APPLICATION_JSON) 36 | .body(BodyInserters.fromValue(props.get("errors"))); 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/configuration/AppConfig.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.configuration; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | 5 | @Configuration 6 | public class AppConfig { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/configuration/security/AuthenticationManager.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.configuration.security; 2 | 3 | import com.github.eriknyk.webfluxjwtsecurity.configuration.security.auth.UnauthorizedException; 4 | import com.github.eriknyk.webfluxjwtsecurity.configuration.security.auth.UserPrincipal; 5 | import com.github.eriknyk.webfluxjwtsecurity.service.UserService; 6 | import org.springframework.security.authentication.ReactiveAuthenticationManager; 7 | import org.springframework.security.core.Authentication; 8 | import org.springframework.stereotype.Component; 9 | import reactor.core.publisher.Mono; 10 | 11 | /** 12 | * AuthenticationManager class 13 | * It is used in AuthenticationFilter. 14 | * 15 | * @author Erik Amaru Ortiz 16 | */ 17 | @Component 18 | public class AuthenticationManager implements ReactiveAuthenticationManager { 19 | private final UserService userService; 20 | 21 | public AuthenticationManager(UserService userService) { 22 | this.userService = userService; 23 | } 24 | 25 | @Override 26 | public Mono authenticate(Authentication authentication) { 27 | var principal = (UserPrincipal) authentication.getPrincipal(); 28 | 29 | //TODO add more user validation logic here. 30 | return userService.getUser(principal.getId()) 31 | .filter(user -> user.isEnabled()) 32 | .switchIfEmpty(Mono.error(new UnauthorizedException("User account is disabled."))) 33 | .map(user -> authentication); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/configuration/security/WebSecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.configuration.security; 2 | 3 | import com.github.eriknyk.webfluxjwtsecurity.configuration.security.support.JwtVerifyHandler; 4 | import com.github.eriknyk.webfluxjwtsecurity.configuration.security.support.ServerHttpBearerAuthenticationConverter; 5 | import com.github.eriknyk.webfluxjwtsecurity.configuration.security.support.ServerHttpCookieAuthenticationConverter; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | import org.springframework.beans.factory.annotation.Value; 9 | import org.springframework.context.annotation.Bean; 10 | import org.springframework.context.annotation.Configuration; 11 | import org.springframework.http.HttpMethod; 12 | import org.springframework.http.HttpStatus; 13 | import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity; 14 | import org.springframework.security.config.web.server.SecurityWebFiltersOrder; 15 | import org.springframework.security.config.web.server.ServerHttpSecurity; 16 | import org.springframework.security.web.server.SecurityWebFilterChain; 17 | import org.springframework.security.web.server.authentication.AuthenticationWebFilter; 18 | import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; 19 | import reactor.core.publisher.Mono; 20 | 21 | /** 22 | * WebSecurityConfig class 23 | * 24 | * @author Erik Amaru Ortiz 25 | */ 26 | @Configuration 27 | @EnableReactiveMethodSecurity 28 | public class WebSecurityConfig { 29 | private final Logger logger = LoggerFactory.getLogger(WebSecurityConfig.class); 30 | 31 | @Value("${jwt.secret}") 32 | private String jwtSecret; 33 | 34 | @Value("${app.public_routes}") 35 | private String[] publicRoutes; 36 | 37 | @Bean 38 | public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, AuthenticationManager authManager) { 39 | return http 40 | .authorizeExchange() 41 | .pathMatchers(HttpMethod.OPTIONS) 42 | .permitAll() 43 | .pathMatchers(publicRoutes) 44 | .permitAll() 45 | .pathMatchers( "/favicon.ico") 46 | .permitAll() 47 | .anyExchange() 48 | .authenticated() 49 | .and() 50 | .csrf() 51 | .disable() 52 | .httpBasic() 53 | .disable() 54 | .formLogin() 55 | .disable() 56 | .exceptionHandling() 57 | .authenticationEntryPoint((swe, e) -> { 58 | logger.info("[1] Authentication error: Unauthorized[401]: " + e.getMessage()); 59 | 60 | return Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED)); 61 | }) 62 | .accessDeniedHandler((swe, e) -> { 63 | logger.info("[2] Authentication error: Access Denied[401]: " + e.getMessage()); 64 | 65 | return Mono.fromRunnable(() -> swe.getResponse().setStatusCode(HttpStatus.FORBIDDEN)); 66 | }) 67 | .and() 68 | .addFilterAt(bearerAuthenticationFilter(authManager), SecurityWebFiltersOrder.AUTHENTICATION) 69 | .addFilterAt(cookieAuthenticationFilter(authManager), SecurityWebFiltersOrder.AUTHENTICATION) 70 | .build(); 71 | } 72 | 73 | /** 74 | * Spring security works by filter chaining. 75 | * We need to add a JWT CUSTOM FILTER to the chain. 76 | * 77 | * what is AuthenticationWebFilter: 78 | * 79 | * A WebFilter that performs authentication of a particular request. An outline of the logic: 80 | * A request comes in and if it does not match setRequiresAuthenticationMatcher(ServerWebExchangeMatcher), 81 | * then this filter does nothing and the WebFilterChain is continued. 82 | * If it does match then... An attempt to convert the ServerWebExchange into an Authentication is made. 83 | * If the result is empty, then the filter does nothing more and the WebFilterChain is continued. 84 | * If it does create an Authentication... 85 | * The ReactiveAuthenticationManager specified in AuthenticationWebFilter(ReactiveAuthenticationManager) is used to perform authentication. 86 | * If authentication is successful, ServerAuthenticationSuccessHandler is invoked and the authentication is set on ReactiveSecurityContextHolder, 87 | * else ServerAuthenticationFailureHandler is invoked 88 | * 89 | */ 90 | AuthenticationWebFilter bearerAuthenticationFilter(AuthenticationManager authManager) { 91 | AuthenticationWebFilter bearerAuthenticationFilter = new AuthenticationWebFilter(authManager); 92 | bearerAuthenticationFilter.setAuthenticationConverter(new ServerHttpBearerAuthenticationConverter(new JwtVerifyHandler(jwtSecret))); 93 | bearerAuthenticationFilter.setRequiresAuthenticationMatcher(ServerWebExchangeMatchers.pathMatchers("/**")); 94 | 95 | return bearerAuthenticationFilter; 96 | } 97 | 98 | AuthenticationWebFilter cookieAuthenticationFilter(AuthenticationManager authManager) { 99 | AuthenticationWebFilter cookieAuthenticationFilter = new AuthenticationWebFilter(authManager); 100 | cookieAuthenticationFilter.setAuthenticationConverter(new ServerHttpCookieAuthenticationConverter(new JwtVerifyHandler(jwtSecret))); 101 | cookieAuthenticationFilter.setRequiresAuthenticationMatcher(ServerWebExchangeMatchers.pathMatchers("/**")); 102 | 103 | return cookieAuthenticationFilter; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/configuration/security/auth/CurrentUserAuthenticationBearer.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.configuration.security.auth; 2 | 3 | import com.github.eriknyk.webfluxjwtsecurity.configuration.security.support.JwtVerifyHandler; 4 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 5 | import org.springframework.security.core.Authentication; 6 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 7 | import reactor.core.publisher.Mono; 8 | 9 | import java.util.List; 10 | import java.util.stream.Collectors; 11 | 12 | /** 13 | * CurrentUserAuthenticationBearer class 14 | * 15 | * @author Erik Amaru Ortiz 16 | */ 17 | public class CurrentUserAuthenticationBearer { 18 | public static Mono create(JwtVerifyHandler.VerificationResult verificationResult) { 19 | var claims = verificationResult.claims; 20 | var subject = claims.getSubject(); 21 | List roles = claims.get("role", List.class); 22 | var authorities = roles.stream() 23 | .map(SimpleGrantedAuthority::new) 24 | .collect(Collectors.toList()); 25 | 26 | var principalId = 0L; 27 | 28 | try { 29 | principalId = Long.parseLong(subject); 30 | } catch (NumberFormatException ignore) { } 31 | 32 | if (principalId == 0) 33 | return Mono.empty(); // invalid value for any of jwt auth parts 34 | 35 | var principal = new UserPrincipal(principalId, claims.getIssuer()); 36 | 37 | return Mono.justOrEmpty(new UsernamePasswordAuthenticationToken(principal, null, authorities)); 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/configuration/security/auth/LocalUserDetails.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.configuration.security.auth; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnore; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | import org.springframework.security.core.GrantedAuthority; 8 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 9 | import org.springframework.security.core.userdetails.UserDetails; 10 | 11 | import java.util.Collection; 12 | import java.util.List; 13 | import java.util.stream.Collectors; 14 | 15 | /** 16 | * LocalUserDetails class 17 | * 18 | * @author Erik Amaru Ortiz 19 | */ 20 | @Data 21 | @AllArgsConstructor 22 | @NoArgsConstructor 23 | public class LocalUserDetails implements UserDetails { 24 | private int id; 25 | private String username; 26 | @JsonIgnore 27 | private String password; 28 | private List roles; 29 | private Boolean enabled; 30 | 31 | @Override 32 | public boolean isAccountNonExpired() { 33 | return false; 34 | } 35 | 36 | @Override 37 | public boolean isAccountNonLocked() { 38 | return false; 39 | } 40 | 41 | @Override 42 | public boolean isCredentialsNonExpired() { 43 | return false; 44 | } 45 | 46 | @Override 47 | public boolean isEnabled() { 48 | return enabled; 49 | } 50 | 51 | @Override 52 | public String getPassword() { 53 | return username; 54 | } 55 | 56 | @Override 57 | public String getUsername() { 58 | return password; 59 | } 60 | 61 | @JsonIgnore 62 | @Override 63 | public Collection getAuthorities() { 64 | return this.roles.stream() 65 | .map(role -> new SimpleGrantedAuthority(role)) 66 | .collect(Collectors.toList()); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/configuration/security/auth/UnauthorizedException.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.configuration.security.auth; 2 | 3 | import com.github.eriknyk.webfluxjwtsecurity.app.ApiException; 4 | import org.springframework.http.HttpStatus; 5 | import org.springframework.web.bind.annotation.ResponseStatus; 6 | 7 | /** 8 | * UnauthorizedException class 9 | * 10 | * @author Erik Amaru Ortiz 11 | */ 12 | @ResponseStatus(value = HttpStatus.UNAUTHORIZED) 13 | public class UnauthorizedException extends ApiException { 14 | public UnauthorizedException(String message) { 15 | super(message, "UNAUTHORIZED"); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/configuration/security/auth/UserPrincipal.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.configuration.security.auth; 2 | 3 | import java.security.Principal; 4 | 5 | /** 6 | * UserPrincipal class 7 | * 8 | * @author Erik Amaru Ortiz 9 | */ 10 | public class UserPrincipal implements Principal { 11 | private Long id; 12 | private String name; 13 | 14 | public UserPrincipal(Long id, String name) { 15 | this.id = id; 16 | this.name = name; 17 | } 18 | 19 | public Long getId() { 20 | return id; 21 | } 22 | 23 | @Override 24 | public String getName() { 25 | return name; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/configuration/security/support/JwtVerifyHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.configuration.security.support; 2 | 3 | import com.github.eriknyk.webfluxjwtsecurity.configuration.security.auth.UnauthorizedException; 4 | import io.jsonwebtoken.Claims; 5 | import io.jsonwebtoken.Jwts; 6 | import reactor.core.publisher.Mono; 7 | 8 | import java.util.Base64; 9 | import java.util.Date; 10 | 11 | /** 12 | * JwtVerifyHandler class 13 | * 14 | * @author Erik Amaru Ortiz 15 | */ 16 | public class JwtVerifyHandler { 17 | private final String secret; 18 | 19 | public JwtVerifyHandler(String secret) { 20 | this.secret = secret; 21 | } 22 | 23 | public Mono check(String accessToken) { 24 | return Mono.just(verify(accessToken)) 25 | .onErrorResume(e -> Mono.error(new UnauthorizedException(e.getMessage()))); 26 | } 27 | 28 | private VerificationResult verify(String token) { 29 | var claims = getAllClaimsFromToken(token); 30 | final Date expiration = claims.getExpiration(); 31 | 32 | if (expiration.before(new Date())) 33 | throw new RuntimeException("Token expired"); 34 | 35 | return new VerificationResult(claims, token); 36 | } 37 | 38 | public Claims getAllClaimsFromToken(String token) { 39 | return Jwts.parser() 40 | .setSigningKey(Base64.getEncoder().encodeToString(secret.getBytes())) 41 | .parseClaimsJws(token) 42 | .getBody(); 43 | } 44 | 45 | public class VerificationResult { 46 | public Claims claims; 47 | public String token; 48 | 49 | public VerificationResult(Claims claims, String token) { 50 | this.claims = claims; 51 | this.token = token; 52 | } 53 | } 54 | } 55 | 56 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/configuration/security/support/PBKDF2Encoder.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.configuration.security.support; 2 | 3 | import org.springframework.beans.factory.annotation.Value; 4 | import org.springframework.security.crypto.password.PasswordEncoder; 5 | import org.springframework.stereotype.Component; 6 | 7 | import javax.crypto.SecretKeyFactory; 8 | import javax.crypto.spec.PBEKeySpec; 9 | import java.security.NoSuchAlgorithmException; 10 | import java.security.spec.InvalidKeySpecException; 11 | import java.util.Base64; 12 | 13 | /** 14 | * PBKDF2Encoder class 15 | * 16 | * @author Erik Amaru Ortiz 17 | */ 18 | @Component 19 | public class PBKDF2Encoder implements PasswordEncoder { 20 | 21 | @Value("${jwt.password.encoder.secret}") 22 | private String secret; 23 | 24 | @Value("${jwt.password.encoder.iteration}") 25 | private Integer iteration; 26 | 27 | @Value("${jwt.password.encoder.keylength}") 28 | private Integer keylength; 29 | 30 | /** 31 | * More info (https://www.owasp.org/index.php/Hashing_Java) 32 | * @param cs password 33 | * @return encoded password 34 | */ 35 | @Override 36 | public String encode(CharSequence cs) { 37 | try { 38 | byte[] result = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512") 39 | .generateSecret(new PBEKeySpec(cs.toString().toCharArray(), secret.getBytes(), iteration, keylength)) 40 | .getEncoded(); 41 | 42 | return Base64.getEncoder() 43 | .encodeToString(result); 44 | } catch (NoSuchAlgorithmException | InvalidKeySpecException ex) { 45 | throw new RuntimeException(ex); 46 | } 47 | } 48 | 49 | @Override 50 | public boolean matches(CharSequence cs, String string) { 51 | return encode(cs).equals(string); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/configuration/security/support/ServerHttpBearerAuthenticationConverter.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.configuration.security.support; 2 | 3 | import com.github.eriknyk.webfluxjwtsecurity.configuration.security.auth.CurrentUserAuthenticationBearer; 4 | import org.springframework.http.HttpHeaders; 5 | import org.springframework.security.core.Authentication; 6 | import org.springframework.web.server.ServerWebExchange; 7 | import reactor.core.publisher.Mono; 8 | 9 | import java.util.function.Function; 10 | import java.util.function.Predicate; 11 | 12 | /** 13 | * ServerHttpBearerAuthenticationConverter class 14 | * This is a Converter that validates TOKEN against requests coming from AuthenticationFilter ServerWebExchange. 15 | * 16 | * @author Erik Amaru Ortiz 17 | */ 18 | public class ServerHttpBearerAuthenticationConverter implements Function> { 19 | private static final String BEARER = "Bearer "; 20 | private static final Predicate matchBearerLength = authValue -> authValue.length() > BEARER.length(); 21 | private static final Function> isolateBearerValue = authValue -> Mono.justOrEmpty(authValue.substring(BEARER.length())); 22 | private final JwtVerifyHandler jwtVerifier; 23 | 24 | public ServerHttpBearerAuthenticationConverter(JwtVerifyHandler jwtVerifier) { 25 | this.jwtVerifier = jwtVerifier; 26 | } 27 | 28 | @Override 29 | public Mono apply(ServerWebExchange serverWebExchange) { 30 | return Mono.justOrEmpty(serverWebExchange) 31 | .flatMap(ServerHttpBearerAuthenticationConverter::extract) 32 | .filter(matchBearerLength) 33 | .flatMap(isolateBearerValue) 34 | .flatMap(jwtVerifier::check) 35 | .flatMap(CurrentUserAuthenticationBearer::create); 36 | } 37 | 38 | public static Mono extract(ServerWebExchange serverWebExchange) { 39 | return Mono.justOrEmpty(serverWebExchange.getRequest() 40 | .getHeaders() 41 | .getFirst(HttpHeaders.AUTHORIZATION)); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/configuration/security/support/ServerHttpCookieAuthenticationConverter.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.configuration.security.support; 2 | 3 | import com.github.eriknyk.webfluxjwtsecurity.configuration.security.auth.CurrentUserAuthenticationBearer; 4 | import org.springframework.security.core.Authentication; 5 | import org.springframework.web.server.ServerWebExchange; 6 | import reactor.core.publisher.Mono; 7 | 8 | import java.util.function.Function; 9 | import java.util.function.Predicate; 10 | 11 | /** 12 | * ServerHttpCookieAuthenticationConverter class 13 | * This is a Converter that validates TOKEN against requests coming from AuthenticationFilter ServerWebExchange. 14 | * 15 | * @author Erik Amaru Ortiz 16 | */ 17 | public class ServerHttpCookieAuthenticationConverter implements Function> { 18 | 19 | private static final String BEARER = "Bearer "; 20 | private static final Predicate matchBearerLength = authValue -> authValue.length() > BEARER.length(); 21 | private static final Function> isolateBearerValue = authValue -> Mono.justOrEmpty(authValue.substring(BEARER.length())); 22 | private final JwtVerifyHandler jwtVerifier; 23 | 24 | public ServerHttpCookieAuthenticationConverter(JwtVerifyHandler jwtVerifier) { 25 | this.jwtVerifier = jwtVerifier; 26 | } 27 | 28 | @Override 29 | public Mono apply(ServerWebExchange serverWebExchange) { 30 | return Mono.justOrEmpty(serverWebExchange) 31 | .flatMap(ServerHttpCookieAuthenticationConverter::extract) 32 | .flatMap(jwtVerifier::check) 33 | .flatMap(CurrentUserAuthenticationBearer::create); 34 | } 35 | 36 | public static Mono extract(ServerWebExchange serverWebExchange) { 37 | var cookieSes = serverWebExchange.getRequest() 38 | .getCookies() 39 | .getFirst("X-Session-Id"); 40 | 41 | return cookieSes != null 42 | ? Mono.justOrEmpty(cookieSes.getValue()) 43 | : Mono.empty(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/controller/AuthController.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.controller; 2 | 3 | import com.github.eriknyk.webfluxjwtsecurity.dto.AuthResultDto; 4 | import com.github.eriknyk.webfluxjwtsecurity.dto.UserLoginDto; 5 | import com.github.eriknyk.webfluxjwtsecurity.service.security.SecurityService; 6 | import org.springframework.http.ResponseEntity; 7 | import org.springframework.web.bind.annotation.PostMapping; 8 | import org.springframework.web.bind.annotation.RequestBody; 9 | import org.springframework.web.bind.annotation.RestController; 10 | import reactor.core.publisher.Mono; 11 | 12 | /** 13 | * AuthController class 14 | * 15 | * @author Erik Amaru Ortiz 16 | */ 17 | @RestController 18 | public class AuthController { 19 | private final SecurityService securityService; 20 | 21 | public AuthController(SecurityService securityService) { 22 | this.securityService = securityService; 23 | } 24 | 25 | @PostMapping("/login") 26 | public Mono> login(@RequestBody UserLoginDto dto) { 27 | return securityService.authenticate(dto.getUsername(), dto.getPassword()) 28 | .flatMap(tokenInfo -> Mono.just(ResponseEntity.ok(AuthResultDto.builder() 29 | .userId(tokenInfo.getUserId()) 30 | .token(tokenInfo.getToken()) 31 | .issuedAt(tokenInfo.getIssuedAt()) 32 | .expiresAt(tokenInfo.getExpiresAt()) 33 | .build()))); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/controller/PublicController.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.controller; 2 | 3 | import com.github.eriknyk.webfluxjwtsecurity.dto.UserDto; 4 | import com.github.eriknyk.webfluxjwtsecurity.dto.mapper.UserMapper; 5 | import com.github.eriknyk.webfluxjwtsecurity.service.UserService; 6 | import org.springframework.web.bind.annotation.*; 7 | import reactor.core.publisher.Mono; 8 | 9 | /** 10 | * PublicController class 11 | * 12 | * @author Erik Amaru Ortiz 13 | */ 14 | @RestController 15 | @RequestMapping("public") 16 | public class PublicController { 17 | private final UserService userService; 18 | private final UserMapper userMapper; 19 | 20 | public PublicController(UserService userService, UserMapper userMapper) { 21 | this.userService = userService; 22 | this.userMapper = userMapper; 23 | } 24 | 25 | /** 26 | * Only for demo purpose!!! 27 | * @return Mono 28 | */ 29 | @PostMapping("/demo-user") 30 | public Mono newUser(@RequestBody UserDto userDto) { 31 | var user = userMapper.map(userDto); 32 | return userService.createUser(user) 33 | .map(u -> userMapper.map(u)); 34 | } 35 | 36 | @GetMapping("/version") 37 | public Mono version() { 38 | return Mono.just("1.0.0"); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/controller/UserController.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.controller; 2 | 3 | import com.github.eriknyk.webfluxjwtsecurity.configuration.security.auth.UserPrincipal; 4 | import com.github.eriknyk.webfluxjwtsecurity.dto.UserDto; 5 | import com.github.eriknyk.webfluxjwtsecurity.dto.mapper.UserMapper; 6 | import com.github.eriknyk.webfluxjwtsecurity.service.UserService; 7 | import org.springframework.security.access.prepost.PreAuthorize; 8 | import org.springframework.security.core.Authentication; 9 | import org.springframework.web.bind.annotation.GetMapping; 10 | import org.springframework.web.bind.annotation.RequestMapping; 11 | import org.springframework.web.bind.annotation.RestController; 12 | import reactor.core.publisher.Mono; 13 | 14 | /** 15 | * UserController class 16 | * 17 | * @author Erik Amaru Ortiz 18 | */ 19 | @RestController 20 | @RequestMapping("user") 21 | @PreAuthorize("hasRole('USER')") 22 | public class UserController { 23 | private final UserService userService; 24 | private final UserMapper userMapper; 25 | 26 | public UserController(UserService userService, UserMapper userMapper) { 27 | this.userService = userService; 28 | this.userMapper = userMapper; 29 | } 30 | 31 | @GetMapping 32 | public Mono get(Authentication authentication) { 33 | var principal = (UserPrincipal) authentication.getPrincipal(); 34 | 35 | return userService.getUser(principal.getId()) 36 | .map(user -> userMapper.map(user)); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/dto/AuthResultDto.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.dto; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | import java.util.Date; 9 | 10 | /** 11 | * AuthResultDto class 12 | * 13 | * @author Erik Amaru Ortiz 14 | */ 15 | @Data 16 | @Builder(toBuilder = true) 17 | @AllArgsConstructor 18 | @NoArgsConstructor 19 | public class AuthResultDto { 20 | private Long userId; 21 | private String token; 22 | private Date issuedAt; 23 | private Date expiresAt; 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/dto/UserDto.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.dto; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import com.fasterxml.jackson.databind.PropertyNamingStrategies; 5 | import com.fasterxml.jackson.databind.annotation.JsonNaming; 6 | 7 | import lombok.Data; 8 | 9 | /** 10 | * UserDto class 11 | * 12 | * @author Erik Amaru Ortiz 13 | */ 14 | @Data 15 | @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) 16 | public class UserDto { 17 | private Long id; 18 | private String username; 19 | @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) 20 | private String password; 21 | private String firstName; 22 | private String lastName; 23 | private boolean enabled; 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/dto/UserLoginDto.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.dto; 2 | 3 | import lombok.Data; 4 | 5 | /** 6 | * UserLoginDto class 7 | * 8 | * @author Erik Amaru Ortiz 9 | */ 10 | @Data 11 | public class UserLoginDto { 12 | private String username; 13 | private String password; 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/dto/mapper/UserMapper.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.dto.mapper; 2 | 3 | import com.github.eriknyk.webfluxjwtsecurity.dto.UserDto; 4 | import com.github.eriknyk.webfluxjwtsecurity.model.user.User; 5 | import org.mapstruct.InheritInverseConfiguration; 6 | import org.mapstruct.Mapper; 7 | 8 | /** 9 | * UserMapper class 10 | * 11 | * @author Erik Amaru Ortiz 12 | */ 13 | @Mapper(componentModel = "spring") 14 | public interface UserMapper { 15 | UserDto map(User user); 16 | 17 | @InheritInverseConfiguration 18 | User map(UserDto userDto); 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/model/auth/TokenInfo.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.model.auth; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | import java.util.Date; 9 | 10 | /** 11 | * TokenInfo class 12 | * 13 | * @author Erik Amaru Ortiz 14 | */ 15 | @Data 16 | @Builder(toBuilder = true) 17 | @NoArgsConstructor 18 | @AllArgsConstructor 19 | public class TokenInfo { 20 | private Long userId; 21 | private String token; 22 | private Date issuedAt; 23 | private Date expiresAt; 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/model/user/User.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.model.user; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | import org.springframework.data.annotation.Id; 8 | import org.springframework.data.relational.core.mapping.Table; 9 | 10 | import java.time.LocalDateTime; 11 | import java.util.List; 12 | 13 | /** 14 | * User class 15 | * 16 | * @author Erik Amaru Ortiz 17 | */ 18 | @Data 19 | @NoArgsConstructor 20 | @AllArgsConstructor 21 | @Builder(toBuilder = true) 22 | @Table("users") 23 | public class User { 24 | @Id 25 | private Long id; 26 | private String username; 27 | private String password; 28 | private List roles; 29 | private String firstName; 30 | private String lastName; 31 | private boolean enabled; 32 | private LocalDateTime createdAt; 33 | private LocalDateTime updatedAt; 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/model/user/UserRepository.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.model.user; 2 | 3 | import org.springframework.data.r2dbc.repository.R2dbcRepository; 4 | import org.springframework.stereotype.Repository; 5 | import reactor.core.publisher.Mono; 6 | 7 | /** 8 | * UserRepository class 9 | * 10 | * @author Erik Amaru Ortiz 11 | */ 12 | @Repository 13 | public interface UserRepository extends R2dbcRepository { 14 | Mono findByUsername(String username); 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/service/UserService.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.service; 2 | 3 | import com.github.eriknyk.webfluxjwtsecurity.model.auth.TokenInfo; 4 | import com.github.eriknyk.webfluxjwtsecurity.model.user.User; 5 | import com.github.eriknyk.webfluxjwtsecurity.model.user.UserRepository; 6 | import com.github.eriknyk.webfluxjwtsecurity.service.security.SecurityService; 7 | import lombok.extern.slf4j.Slf4j; 8 | import org.springframework.security.crypto.password.PasswordEncoder; 9 | import org.springframework.stereotype.Service; 10 | import reactor.core.publisher.Mono; 11 | 12 | import java.time.LocalDateTime; 13 | import java.util.Collections; 14 | 15 | /** 16 | * UserService class 17 | * 18 | * @author Erik Amaru Ortiz 19 | */ 20 | @Slf4j 21 | @Service 22 | public class UserService { 23 | private final SecurityService securityService; 24 | private final UserRepository userRepository; 25 | private final PasswordEncoder passwordEncoder; 26 | 27 | public UserService(SecurityService securityService, UserRepository userRepository, PasswordEncoder passwordEncoder) { 28 | this.securityService = securityService; 29 | this.userRepository = userRepository; 30 | this.passwordEncoder = passwordEncoder; 31 | } 32 | 33 | public Mono createUser(User user) { 34 | return userRepository.save(user.toBuilder() 35 | .password(passwordEncoder.encode(user.getPassword())) 36 | .roles(Collections.singletonList("ROLE_USER")) 37 | .enabled(true) 38 | .createdAt(LocalDateTime.now()) 39 | .build()) 40 | .doOnSuccess(u -> log.info("Created new user with ID = " + u.getId())); 41 | } 42 | 43 | public Mono getUser(Long userId) { 44 | return userRepository.findById(userId); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/service/security/AuthException.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.service.security; 2 | 3 | import com.github.eriknyk.webfluxjwtsecurity.app.ApiException; 4 | 5 | /** 6 | * AuthException class 7 | * 8 | * @author Erik Amaru Ortiz 9 | */ 10 | public class AuthException extends ApiException { 11 | public AuthException(String message, String errorCode) { 12 | super(message, errorCode); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/github/eriknyk/webfluxjwtsecurity/service/security/SecurityService.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity.service.security; 2 | 3 | import com.github.eriknyk.webfluxjwtsecurity.model.auth.TokenInfo; 4 | import com.github.eriknyk.webfluxjwtsecurity.model.user.User; 5 | import com.github.eriknyk.webfluxjwtsecurity.model.user.UserRepository; 6 | import io.jsonwebtoken.Jwts; 7 | import io.jsonwebtoken.SignatureAlgorithm; 8 | import org.springframework.beans.factory.annotation.Value; 9 | import org.springframework.security.crypto.password.PasswordEncoder; 10 | import org.springframework.stereotype.Component; 11 | import reactor.core.publisher.Mono; 12 | 13 | import java.io.Serializable; 14 | import java.util.*; 15 | 16 | /** 17 | * SecurityService class 18 | * 19 | * @author Erik Amaru Ortiz 20 | */ 21 | @Component 22 | public class SecurityService implements Serializable { 23 | private final UserRepository userRepository; 24 | private final PasswordEncoder passwordEncoder; 25 | 26 | @Value("${jwt.secret}") 27 | private String secret; 28 | 29 | @Value("${jwt.expiration}") 30 | private String defaultExpirationTimeInSecondsConf; 31 | 32 | public SecurityService(UserRepository userRepository, PasswordEncoder passwordEncoder) { 33 | this.userRepository = userRepository; 34 | this.passwordEncoder = passwordEncoder; 35 | } 36 | 37 | public TokenInfo generateAccessToken(User user) { 38 | var claims = new HashMap() {{ 39 | put("role", user.getRoles()); 40 | }}; 41 | 42 | return doGenerateToken(claims, user.getUsername(), user.getId().toString()); 43 | } 44 | 45 | private TokenInfo doGenerateToken(Map claims, String issuer, String subject) { 46 | var expirationTimeInMilliseconds = Long.parseLong(defaultExpirationTimeInSecondsConf) * 1000; 47 | var expirationDate = new Date(new Date().getTime() + expirationTimeInMilliseconds); 48 | 49 | return doGenerateToken(expirationDate, claims, issuer, subject); 50 | } 51 | 52 | private TokenInfo doGenerateToken(Date expirationDate, Map claims, String issuer, String subject) { 53 | var createdDate = new Date(); 54 | var token = Jwts.builder() 55 | .setClaims(claims) 56 | .setIssuer(issuer) 57 | .setSubject(subject) 58 | .setIssuedAt(createdDate) 59 | .setId(UUID.randomUUID().toString()) 60 | .setExpiration(expirationDate) 61 | .signWith(SignatureAlgorithm.HS256, Base64.getEncoder().encodeToString(secret.getBytes())) 62 | .compact(); 63 | 64 | return TokenInfo.builder() 65 | .token(token) 66 | .issuedAt(createdDate) 67 | .expiresAt(expirationDate) 68 | .build(); 69 | } 70 | 71 | public Mono authenticate(String username, String password) { 72 | return userRepository.findByUsername(username) 73 | .flatMap(user -> { 74 | if (!user.isEnabled()) 75 | return Mono.error(new AuthException("Account disabled.", "USER_ACCOUNT_DISABLED")); 76 | 77 | if (!passwordEncoder.encode(password).equals(user.getPassword())) 78 | return Mono.error(new AuthException("Invalid user password!", "INVALID_USER_PASSWORD")); 79 | 80 | return Mono.just(generateAccessToken(user).toBuilder() 81 | .userId(user.getId()) 82 | .build()); 83 | }) 84 | .switchIfEmpty(Mono.error(new AuthException("Invalid user, " + username + " is not registered.", "INVALID_USERNAME"))); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/main/resources/application.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 9000 3 | spring: 4 | r2dbc: 5 | url: r2dbc:pool:postgres://localhost:5432/webflux-security 6 | username: postgres 7 | password: postgres 8 | jackson: 9 | default-property-inclusion: non-null 10 | visibility: 11 | field: any 12 | app: 13 | public_routes: /login,/public/**,/version,/status,/actuator/** 14 | jwt: 15 | password: 16 | encoder: 17 | secret: oZr417KU7ipPoCCGY0-cPcGu0PpT1_aG9o-BD1KcnN3BpZPrLcNKgcF9QXXJwrY50Whd7Ij51t45oD0ctn-Vo032uFoMtnNzvzpOFq 18 | iteration: 33 19 | keylength: 256 20 | secret: AMDXGbO7gGwf3hoFPpm6GwQvFrqoCsn2 #This Secret For JWT HS256 Signature Algorithm MUST Have 256 bits KeySize 21 | expiration: 28800 # token expiration in seconds 22 | 23 | -------------------------------------------------------------------------------- /src/main/resources/schema/database.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE "webflux-security"; 2 | 3 | DROP TABLE IF EXISTS users; 4 | CREATE TABLE users 5 | ( 6 | id SERIAL PRIMARY KEY, 7 | username VARCHAR(64), 8 | password VARCHAR(64), 9 | roles TEXT[], 10 | first_name VARCHAR(64), 11 | last_name VARCHAR(64), 12 | enabled BOOLEAN, 13 | created_at TIMESTAMP, 14 | updated_at TIMESTAMP 15 | ); 16 | -------------------------------------------------------------------------------- /src/test/java/com/github/eriknyk/webfluxjwtsecurity/WebfluxSecurityApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.github.eriknyk.webfluxjwtsecurity; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.boot.test.context.SpringBootTest; 5 | 6 | @SpringBootTest 7 | class WebfluxSecurityApplicationTests { 8 | 9 | @Test 10 | void contextLoads() { 11 | } 12 | 13 | } 14 | --------------------------------------------------------------------------------