├── .editorconfig ├── .gitignore ├── LICENSE.txt ├── NOTICE ├── README.md ├── pom.xml └── src ├── main ├── java │ └── com │ │ └── innoq │ │ └── cookiebasedsessionapp │ │ ├── CookieBasedSessionSpringBootApplication.java │ │ ├── CookieSecurityContextRepository.java │ │ ├── CookieVerificationFailedException.java │ │ ├── InMemoryAuthenticationProvider.java │ │ ├── LoginWithTargetUrlAuthenticationEntryPoint.java │ │ ├── MvcConfig.java │ │ ├── RedirectToOriginalUrlAuthenticationSuccessHandler.java │ │ ├── SignedUserInfoCookie.java │ │ ├── UserInfo.java │ │ └── WebSecurityConfig.java └── resources │ ├── application.yml │ └── templates │ ├── index.html │ ├── login.html │ └── other.html └── test └── java └── com └── innoq └── cookiebasedsessionapp ├── CookieSecurityContextRepositoryTest.java ├── LoginWithTargetUrlAuthenticationEntryPointTest.java ├── RedirectToOriginalUrlAuthenticationSuccessHandlerTest.java └── SignedUserInfoCookieTest.java /.editorconfig: -------------------------------------------------------------------------------- 1 | # see http://editorconfig.org for more details 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | 8 | [{*.java,*.yml,*.yaml}] 9 | indent_style = space 10 | indent_size = 2 11 | trim_trailing_whitespace = true 12 | charset = utf-8 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | *.iml 3 | .idea/ 4 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | cookie-based-session-springboot-app 2 | Copyright (C) 2020 innoQ Deutschland GmbH 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cookie-based Session Spring-Boot App 2 | 3 | This project contains a very simple spring-boot application that stores its user session 4 | information (e.g. username, roles) in a cookie instead of persisting it on the server-side. 5 | 6 | ## Usage 7 | 8 | Just as any other spring-boot app it can be started as follows 9 | 10 | mvn spring-boot:run 11 | 12 | It listens on port 8080 and provides the following pages 13 | 14 | * `/` - home page, requires authentication 15 | * `/other` - other page, requires authentication 16 | * `/login` - login form 17 | 18 | It uses an in-memory authentication manager which knows exactly one set of valid credentials: 19 | `bob` / `builder` 20 | 21 | ## Test 22 | 23 | 1. open `http://localhost:8080/other` 24 | * forwarded to `http://localhost:8080/login?target=/other` (login form) 25 | * hidden input field `target` contains originally requested URL 26 | 2. login with credentials 27 | * forwarded to `http://localhost:8080/other` (other page) 28 | * `UserInfo` cookie was set, value: `uid=bob&roles=TESTER|USER&hmac=...` 29 | 3. open `http://localhost:8080/` 30 | * home page is displayed (authentication still valid) 31 | 4. logout 32 | * forward to login form 33 | * hidden input field `target` is empty (no URL requested) 34 | * `UserInfo` cookie was deleted 35 | 36 | ## Solution (brief summary) 37 | 38 | Details can be found in the code. The `WebSecurityConfig` class is a good entry point. 39 | 40 | A more detailed description can be found in a according [blog post][]. 41 | 42 | ### `SessionCreationPolicy.STATELESS` 43 | 44 | See https://docs.spring.io/spring-security/site/docs/5.3.3.RELEASE/api/org/springframework/security/config/http/SessionCreationPolicy.html#STATELESS 45 | 46 | Prevents the creation of the server-side session. CSRF is strongly coupled with the 47 | server-side session so it has to be disabled as well to really activate the policy 48 | (see https://github.com/spring-projects/spring-security/issues/5299). 49 | 50 | ```java 51 | protected void configure(HttpSecurity http) throws Exception { 52 | http 53 | ... 54 | 55 | .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) 56 | .and().csrf().disable() 57 | 58 | ... 59 | } 60 | ``` 61 | 62 | ### `CookieSecurityContextRepository` 63 | 64 | Replaces the default `HttpSessionSecurityContextRepository` and persists the `SecurityContext` 65 | in a `Cookie`. 66 | 67 | ```java 68 | protected void configure(HttpSecurity http) throws Exception { 69 | http 70 | ... 71 | 72 | .securityContext().securityContextRepository(cookieSecurityContextRepository) 73 | .and().logout().permitAll().deleteCookies(UserInfoCookie.NAME) 74 | 75 | ... 76 | } 77 | ``` 78 | 79 | ### `LoginWithTargetUrlAuthenticationEntryPoint` und `RedirectToOriginalUrlAuthenticationSuccessHandler` 80 | 81 | The default `RequestCache` is deactivated and instead the `LoginWithTargetUrlAuthenticationEntryPoint` is used to add 82 | the originally requested URL to the login form request. 83 | 84 | The `RedirectToOriginalUrlAuthenticationSuccessHandler` is used to forward the user to the originally requested URL after 85 | a successful login. 86 | 87 | ```java 88 | protected void configure(HttpSecurity http) throws Exception { 89 | http 90 | ... 91 | 92 | .and().requestCache().disable() 93 | .exceptionHandling().authenticationEntryPoint(loginWithTargetUrlAuthenticationEntryPoint) 94 | 95 | .and().formLogin() 96 | .loginPage(LOGIN_FORM_URL) 97 | .successHandler(redirectToOriginalUrlAuthenticationSuccessHandler) 98 | 99 | ... 100 | } 101 | ``` 102 | 103 | --- 104 | 105 | [blog post]: https://innoq.com/en/blog/cookie-based-spring-security-session/ 106 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | 8 | org.springframework.boot 9 | spring-boot-starter-parent 10 | 2.3.1.RELEASE 11 | 12 | 13 | 14 | com.innoq 15 | cookie-based-session-springboot-app 16 | 1.0-SNAPSHOT 17 | 18 | Sample Spring Boot app using Spring Security that stores user session information in a cookie instead of having a server-side persisted session. 19 | https://github.com/innoq/cookie-based-session-springboot-app 20 | 21 | 22 | innoQ Deutschland GmbH 23 | https://innoq.com 24 | 25 | 26 | 27 | 28 | The Apache Software License, Version 2.0 29 | http://www.apache.org/licenses/LICENSE-2.0.txt 30 | repo 31 | 32 | 33 | 34 | 35 | 36 | tma 37 | Torsten Mandry 38 | torsten.mandry@innoq dot com 39 | 40 | 41 | 42 | 43 | scm:git:git@github.com:innoq/cookie-based-session-springboot-app.git 44 | scm:git:git@github.com:innoq/cookie-based-session-springboot-app.git 45 | git@github.com:innoq/cookie-based-session-springboot-app.git 46 | HEAD 47 | 48 | 49 | 50 | GitHub 51 | https://github.com/innoq/cookie-based-session-springboot-app/issues 52 | 53 | 54 | 55 | UTF-8 56 | 11 57 | 58 | 59 | 60 | 61 | org.springframework.boot 62 | spring-boot-starter-web 63 | 64 | 65 | org.springframework.boot 66 | spring-boot-starter-thymeleaf 67 | 68 | 69 | org.springframework.boot 70 | spring-boot-starter-security 71 | 72 | 73 | 74 | org.springframework.boot 75 | spring-boot-devtools 76 | runtime 77 | true 78 | 79 | 80 | org.springframework.boot 81 | spring-boot-starter-test 82 | test 83 | 84 | 85 | org.junit.vintage 86 | junit-vintage-engine 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | org.springframework.boot 96 | spring-boot-maven-plugin 97 | 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /src/main/java/com/innoq/cookiebasedsessionapp/CookieBasedSessionSpringBootApplication.java: -------------------------------------------------------------------------------- 1 | package com.innoq.cookiebasedsessionapp; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class CookieBasedSessionSpringBootApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(CookieBasedSessionSpringBootApplication.class, args); 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/innoq/cookiebasedsessionapp/CookieSecurityContextRepository.java: -------------------------------------------------------------------------------- 1 | package com.innoq.cookiebasedsessionapp; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | import org.springframework.beans.factory.annotation.Value; 6 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 7 | import org.springframework.security.core.Authentication; 8 | import org.springframework.security.core.context.SecurityContext; 9 | import org.springframework.security.core.context.SecurityContextHolder; 10 | import org.springframework.security.web.context.HttpRequestResponseHolder; 11 | import org.springframework.security.web.context.SaveContextOnUpdateOrErrorResponseWrapper; 12 | import org.springframework.security.web.context.SecurityContextRepository; 13 | import org.springframework.stereotype.Component; 14 | 15 | import javax.servlet.http.Cookie; 16 | import javax.servlet.http.HttpServletRequest; 17 | import javax.servlet.http.HttpServletResponse; 18 | import java.util.Optional; 19 | import java.util.stream.Stream; 20 | 21 | @Component 22 | public class CookieSecurityContextRepository implements SecurityContextRepository { 23 | 24 | private static final Logger LOG = LoggerFactory.getLogger(CookieSecurityContextRepository.class); 25 | private static final String EMPTY_CREDENTIALS = ""; 26 | private static final String ANONYMOUS_USER = "anonymousUser"; 27 | 28 | private final String cookieHmacKey; 29 | 30 | public CookieSecurityContextRepository(@Value("${auth.cookie.hmac-key}") String cookieHmacKey) { 31 | this.cookieHmacKey = cookieHmacKey; 32 | } 33 | 34 | @Override 35 | public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) { 36 | HttpServletRequest request = requestResponseHolder.getRequest(); 37 | HttpServletResponse response = requestResponseHolder.getResponse(); 38 | requestResponseHolder.setResponse(new SaveToCookieResponseWrapper(request, response)); 39 | 40 | SecurityContext context = SecurityContextHolder.createEmptyContext(); 41 | readUserInfoFromCookie(request).ifPresent(userInfo -> 42 | context.setAuthentication(new UsernamePasswordAuthenticationToken(userInfo, EMPTY_CREDENTIALS, userInfo.getAuthorities()))); 43 | 44 | return context; 45 | } 46 | 47 | @Override 48 | public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) { 49 | SaveToCookieResponseWrapper responseWrapper = (SaveToCookieResponseWrapper) response; 50 | if (!responseWrapper.isContextSaved()) { 51 | responseWrapper.saveContext(context); 52 | } 53 | } 54 | 55 | @Override 56 | public boolean containsContext(HttpServletRequest request) { 57 | return readUserInfoFromCookie(request).isPresent(); 58 | } 59 | 60 | private Optional readUserInfoFromCookie(HttpServletRequest request) { 61 | return readCookieFromRequest(request) 62 | .map(this::createUserInfo); 63 | } 64 | 65 | private Optional readCookieFromRequest(HttpServletRequest request) { 66 | if (request.getCookies() == null) { 67 | LOG.debug("No cookies in request"); 68 | return Optional.empty(); 69 | } 70 | 71 | Optional maybeCookie = Stream.of(request.getCookies()) 72 | .filter(c -> SignedUserInfoCookie.NAME.equals(c.getName())) 73 | .findFirst(); 74 | 75 | if (maybeCookie.isEmpty()) { 76 | LOG.debug("No {} cookie in request", SignedUserInfoCookie.NAME); 77 | } 78 | 79 | return maybeCookie; 80 | } 81 | 82 | private UserInfo createUserInfo(Cookie cookie) { 83 | return new SignedUserInfoCookie(cookie, cookieHmacKey).getUserInfo(); 84 | } 85 | 86 | private class SaveToCookieResponseWrapper extends SaveContextOnUpdateOrErrorResponseWrapper { 87 | private final Logger LOG = LoggerFactory.getLogger(SaveToCookieResponseWrapper.class); 88 | private final HttpServletRequest request; 89 | 90 | SaveToCookieResponseWrapper(HttpServletRequest request, HttpServletResponse response) { 91 | super(response, true); 92 | this.request = request; 93 | } 94 | 95 | @Override 96 | protected void saveContext(SecurityContext securityContext) { 97 | HttpServletResponse response = (HttpServletResponse) getResponse(); 98 | Authentication authentication = securityContext.getAuthentication(); 99 | if (authentication == null) { 100 | LOG.debug("No securityContext.authentication, skip saveContext"); 101 | return; 102 | } 103 | 104 | if (ANONYMOUS_USER.equals(authentication.getPrincipal())) { 105 | LOG.debug("Anonymous User SecurityContext, skip saveContext"); 106 | return; 107 | } 108 | 109 | if (!(authentication.getPrincipal() instanceof UserInfo)) { 110 | LOG.warn("securityContext.authentication.principal of unexpected type {}, skip saveContext", authentication.getPrincipal().getClass().getCanonicalName()); 111 | return; 112 | } 113 | 114 | UserInfo userInfo = (UserInfo) authentication.getPrincipal(); 115 | SignedUserInfoCookie cookie = new SignedUserInfoCookie(userInfo, cookieHmacKey); 116 | cookie.setSecure(request.isSecure()); 117 | response.addCookie(cookie); 118 | LOG.debug("SecurityContext for principal '{}' saved in Cookie", userInfo.getUsername()); 119 | } 120 | } 121 | 122 | } 123 | -------------------------------------------------------------------------------- /src/main/java/com/innoq/cookiebasedsessionapp/CookieVerificationFailedException.java: -------------------------------------------------------------------------------- 1 | package com.innoq.cookiebasedsessionapp; 2 | 3 | public class CookieVerificationFailedException extends RuntimeException { 4 | public CookieVerificationFailedException(String message) { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/innoq/cookiebasedsessionapp/InMemoryAuthenticationProvider.java: -------------------------------------------------------------------------------- 1 | package com.innoq.cookiebasedsessionapp; 2 | 3 | import org.springframework.security.authentication.AuthenticationProvider; 4 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 5 | import org.springframework.security.core.Authentication; 6 | import org.springframework.security.core.AuthenticationException; 7 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 8 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 9 | import org.springframework.stereotype.Component; 10 | 11 | import java.util.Collection; 12 | import java.util.Set; 13 | 14 | @Component 15 | class InMemoryAuthenticationProvider implements AuthenticationProvider { 16 | 17 | private static final Collection userInfos = Set.of( 18 | new UserInfo("bob", "builder", 19 | Set.of(new SimpleGrantedAuthority("USER"), new SimpleGrantedAuthority("TESTER")))); 20 | 21 | @Override 22 | public Authentication authenticate(Authentication authentication) throws AuthenticationException { 23 | UserInfo userInfo = InMemoryAuthenticationProvider.userInfos.stream() 24 | .filter(b -> b.getUsername().equals(authentication.getName())) 25 | .findFirst() 26 | .orElseThrow(() -> new UsernameNotFoundException("")); 27 | return new UsernamePasswordAuthenticationToken(userInfo, userInfo.getPassword(), userInfo.getAuthorities()); 28 | } 29 | 30 | @Override 31 | public boolean supports(Class authentication) { 32 | return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/com/innoq/cookiebasedsessionapp/LoginWithTargetUrlAuthenticationEntryPoint.java: -------------------------------------------------------------------------------- 1 | package com.innoq.cookiebasedsessionapp; 2 | 3 | import org.springframework.security.core.AuthenticationException; 4 | import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; 5 | import org.springframework.stereotype.Component; 6 | import org.springframework.web.util.UriComponentsBuilder; 7 | 8 | import javax.servlet.http.HttpServletRequest; 9 | import javax.servlet.http.HttpServletResponse; 10 | 11 | @Component 12 | public class LoginWithTargetUrlAuthenticationEntryPoint extends LoginUrlAuthenticationEntryPoint { 13 | 14 | public LoginWithTargetUrlAuthenticationEntryPoint() { 15 | super(WebSecurityConfig.LOGIN_FORM_URL); 16 | } 17 | 18 | @Override 19 | protected String determineUrlToUseForThisRequest(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) { 20 | return UriComponentsBuilder.fromUriString(super.determineUrlToUseForThisRequest(request, response, exception)) 21 | .queryParam(WebSecurityConfig.TARGET_AFTER_SUCCESSFUL_LOGIN_PARAM, request.getRequestURI()) 22 | .toUriString(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/innoq/cookiebasedsessionapp/MvcConfig.java: -------------------------------------------------------------------------------- 1 | package com.innoq.cookiebasedsessionapp; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; 5 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 6 | 7 | @Configuration 8 | public class MvcConfig implements WebMvcConfigurer { 9 | public void addViewControllers(ViewControllerRegistry registry) { 10 | registry.addViewController("/").setViewName("index"); 11 | registry.addViewController("/other").setViewName("other"); 12 | registry.addViewController("/login").setViewName("login"); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/innoq/cookiebasedsessionapp/RedirectToOriginalUrlAuthenticationSuccessHandler.java: -------------------------------------------------------------------------------- 1 | package com.innoq.cookiebasedsessionapp; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | import org.springframework.security.core.Authentication; 6 | import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; 7 | import org.springframework.security.web.util.UrlUtils; 8 | import org.springframework.stereotype.Component; 9 | 10 | import javax.servlet.ServletException; 11 | import javax.servlet.http.HttpServletRequest; 12 | import javax.servlet.http.HttpServletResponse; 13 | import java.io.IOException; 14 | 15 | @Component 16 | public class RedirectToOriginalUrlAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { 17 | private static final Logger LOG = LoggerFactory.getLogger(RedirectToOriginalUrlAuthenticationSuccessHandler.class); 18 | private static final String DEFAULT_TARGET_URL = "/"; 19 | 20 | 21 | public RedirectToOriginalUrlAuthenticationSuccessHandler() { 22 | super(DEFAULT_TARGET_URL); 23 | this.setTargetUrlParameter(WebSecurityConfig.TARGET_AFTER_SUCCESSFUL_LOGIN_PARAM); 24 | } 25 | 26 | @Override 27 | public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { 28 | UserInfo userInfo = (UserInfo) authentication.getPrincipal(); 29 | userInfo.setColour(request.getParameter(WebSecurityConfig.COLOUR_PARAM)); 30 | super.onAuthenticationSuccess(request, response, authentication); 31 | } 32 | 33 | @Override 34 | protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { 35 | var targetUrl = super.determineTargetUrl(request, response, authentication); 36 | if (UrlUtils.isAbsoluteUrl(targetUrl)) { 37 | LOG.warn("Absolute target URL {} identified and suppressed", targetUrl); 38 | return DEFAULT_TARGET_URL; 39 | } 40 | return targetUrl; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/com/innoq/cookiebasedsessionapp/SignedUserInfoCookie.java: -------------------------------------------------------------------------------- 1 | package com.innoq.cookiebasedsessionapp; 2 | 3 | import org.springframework.security.core.GrantedAuthority; 4 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 5 | 6 | import javax.crypto.Mac; 7 | import javax.crypto.spec.SecretKeySpec; 8 | import javax.servlet.http.Cookie; 9 | import java.nio.charset.StandardCharsets; 10 | import java.security.InvalidKeyException; 11 | import java.security.NoSuchAlgorithmException; 12 | import java.time.Duration; 13 | import java.time.temporal.ChronoUnit; 14 | import java.util.Base64; 15 | import java.util.List; 16 | import java.util.Objects; 17 | import java.util.Optional; 18 | import java.util.regex.Matcher; 19 | import java.util.regex.Pattern; 20 | import java.util.stream.Collectors; 21 | 22 | import static java.util.stream.Collectors.toList; 23 | 24 | public class SignedUserInfoCookie extends Cookie { 25 | 26 | public static final String NAME = "UserInfo"; 27 | private static final String PATH = "/"; 28 | private static final Pattern UID_PATTERN = Pattern.compile("uid=([A-Za-z0-9]*)"); 29 | private static final Pattern ROLES_PATTERN = Pattern.compile("roles=([A-Z0-9_|]*)"); 30 | private static final Pattern COLOUR_PATTERN = Pattern.compile("colour=([A-Z]*)"); 31 | private static final Pattern HMAC_PATTERN = Pattern.compile("hmac=([A-Za-z0-9+/=]*)"); 32 | private static final String HMAC_SHA_512 = "HmacSHA512"; 33 | 34 | private final Payload payload; 35 | private final String hmac; 36 | 37 | public SignedUserInfoCookie(UserInfo userInfo, String cookieHmacKey) { 38 | super(NAME, ""); 39 | this.payload = new Payload( 40 | userInfo.getUsername(), 41 | userInfo.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(toList()), 42 | userInfo.getColour().orElse(null)); 43 | this.hmac = calculateHmac(this.payload, cookieHmacKey); 44 | this.setPath(PATH); 45 | this.setMaxAge((int) Duration.of(1, ChronoUnit.HOURS).toSeconds()); 46 | this.setHttpOnly(true); 47 | } 48 | 49 | public SignedUserInfoCookie(Cookie cookie, String cookieHmacKey) { 50 | super(NAME, ""); 51 | 52 | if (!NAME.equals(cookie.getName())) 53 | throw new IllegalArgumentException("No " + NAME + " Cookie"); 54 | 55 | this.hmac = parse(cookie.getValue(), HMAC_PATTERN).orElse(null); 56 | if (hmac == null) 57 | throw new CookieVerificationFailedException("Cookie not signed (no HMAC)"); 58 | 59 | String username = parse(cookie.getValue(), UID_PATTERN).orElseThrow(() -> new IllegalArgumentException(NAME + " Cookie contains no UID")); 60 | List roles = parse(cookie.getValue(), ROLES_PATTERN).map(s -> List.of(s.split("\\|"))).orElse(List.of()); 61 | String colour = parse(cookie.getValue(), COLOUR_PATTERN).orElse(null); 62 | this.payload = new Payload(username, roles, colour); 63 | 64 | if (!hmac.equals(calculateHmac(payload, cookieHmacKey))) 65 | throw new CookieVerificationFailedException("Cookie signature (HMAC) invalid"); 66 | 67 | this.setPath(cookie.getPath()); 68 | this.setMaxAge(cookie.getMaxAge()); 69 | this.setHttpOnly(cookie.isHttpOnly()); 70 | } 71 | 72 | private Optional parse(String value, Pattern pattern) { 73 | Matcher matcher = pattern.matcher(value); 74 | if (!matcher.find()) 75 | return Optional.empty(); 76 | 77 | if (matcher.groupCount() < 1) 78 | return Optional.empty(); 79 | 80 | String match = matcher.group(1); 81 | if (match == null || match.trim().isEmpty()) 82 | return Optional.empty(); 83 | 84 | return Optional.of(match); 85 | } 86 | 87 | @Override 88 | public String getValue() { 89 | return payload.toString() + "&hmac=" + hmac; 90 | } 91 | 92 | public UserInfo getUserInfo() { 93 | return new UserInfo( 94 | payload.username, 95 | payload.roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toSet()), 96 | payload.colour); 97 | } 98 | 99 | private String calculateHmac(Payload payload, String secretKey) { 100 | byte[] secretKeyBytes = Objects.requireNonNull(secretKey).getBytes(StandardCharsets.UTF_8); 101 | byte[] valueBytes = Objects.requireNonNull(payload).toString().getBytes(StandardCharsets.UTF_8); 102 | 103 | try { 104 | Mac mac = Mac.getInstance(HMAC_SHA_512); 105 | SecretKeySpec secretKeySpec = new SecretKeySpec(secretKeyBytes, HMAC_SHA_512); 106 | mac.init(secretKeySpec); 107 | byte[] hmacBytes = mac.doFinal(valueBytes); 108 | return Base64.getEncoder().encodeToString(hmacBytes); 109 | 110 | } catch (NoSuchAlgorithmException | InvalidKeyException e) { 111 | throw new RuntimeException(e); 112 | } 113 | } 114 | 115 | private static class Payload { 116 | private final String username; 117 | private final List roles; 118 | private final String colour; 119 | 120 | private Payload(String username, List roles, String colour) { 121 | this.username = username; 122 | this.roles = roles; 123 | this.colour = colour; 124 | } 125 | 126 | @Override 127 | public String toString() { 128 | return "uid=" + username + 129 | "&roles=" + String.join("|", roles) + 130 | (colour != null ? "&colour=" + colour : ""); 131 | } 132 | } 133 | 134 | /** 135 | * Only for testing. 136 | */ 137 | String getUsername() { 138 | return payload.username; 139 | } 140 | 141 | /** 142 | * Only for testing. 143 | */ 144 | List getRoles() { 145 | return payload.roles; 146 | } 147 | 148 | /** 149 | * Only for testing. 150 | */ 151 | String getColour() { 152 | return payload.colour; 153 | } 154 | 155 | /** 156 | * Only for testing. 157 | */ 158 | String getHmac() { 159 | return hmac; 160 | } 161 | 162 | } 163 | -------------------------------------------------------------------------------- /src/main/java/com/innoq/cookiebasedsessionapp/UserInfo.java: -------------------------------------------------------------------------------- 1 | package com.innoq.cookiebasedsessionapp; 2 | 3 | import org.springframework.security.core.GrantedAuthority; 4 | import org.springframework.security.core.userdetails.UserDetails; 5 | 6 | import java.util.Collection; 7 | import java.util.Optional; 8 | import java.util.Set; 9 | 10 | public class UserInfo implements UserDetails { 11 | 12 | private static final String EMPTY_PASSWORD = ""; 13 | 14 | private final String username; 15 | private final String password; 16 | private final Set authorities; 17 | 18 | private String colour; 19 | 20 | UserInfo(String username, Set authorities) { 21 | this(username, "", authorities); 22 | } 23 | 24 | UserInfo(String username, Set authorities, String colour) { 25 | this(username, "", authorities); 26 | this.colour = colour; 27 | } 28 | 29 | UserInfo(String username, String password, Set authorities) { 30 | this.username = username; 31 | this.password = password; 32 | this.authorities = authorities; 33 | } 34 | 35 | @Override 36 | public Collection getAuthorities() { 37 | return authorities; 38 | } 39 | 40 | @Override 41 | public String getPassword() { 42 | return EMPTY_PASSWORD; 43 | } 44 | 45 | @Override 46 | public String getUsername() { 47 | return username; 48 | } 49 | 50 | @Override 51 | public boolean isAccountNonExpired() { 52 | return true; 53 | } 54 | 55 | @Override 56 | public boolean isAccountNonLocked() { 57 | return true; 58 | } 59 | 60 | @Override 61 | public boolean isCredentialsNonExpired() { 62 | return true; 63 | } 64 | 65 | @Override 66 | public boolean isEnabled() { 67 | return true; 68 | } 69 | 70 | public Optional getColour() { 71 | return Optional.ofNullable(colour); 72 | } 73 | 74 | public void setColour(String colour) { 75 | if (colour == null || colour.isBlank()) 76 | this.colour = null; 77 | else 78 | this.colour = colour; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/main/java/com/innoq/cookiebasedsessionapp/WebSecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.innoq.cookiebasedsessionapp; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; 5 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 6 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 7 | import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; 8 | import org.springframework.security.config.http.SessionCreationPolicy; 9 | 10 | @Configuration 11 | @EnableWebSecurity 12 | public class WebSecurityConfig extends WebSecurityConfigurerAdapter { 13 | 14 | static final String LOGIN_FORM_URL = "/login"; 15 | static final String TARGET_AFTER_SUCCESSFUL_LOGIN_PARAM = "target"; 16 | static final String COLOUR_PARAM = "colour"; 17 | 18 | private final CookieSecurityContextRepository cookieSecurityContextRepository; 19 | private final LoginWithTargetUrlAuthenticationEntryPoint loginWithTargetUrlAuthenticationEntryPoint; 20 | private final RedirectToOriginalUrlAuthenticationSuccessHandler redirectToOriginalUrlAuthenticationSuccessHandler; 21 | private final InMemoryAuthenticationProvider inMemoryAuthenticationProvider; 22 | 23 | protected WebSecurityConfig(CookieSecurityContextRepository cookieSecurityContextRepository, 24 | LoginWithTargetUrlAuthenticationEntryPoint loginWithTargetUrlAuthenticationEntryPoint, 25 | RedirectToOriginalUrlAuthenticationSuccessHandler redirectToOriginalUrlAuthenticationSuccessHandler, 26 | InMemoryAuthenticationProvider inMemoryAuthenticationProvider) { 27 | super(); 28 | this.cookieSecurityContextRepository = cookieSecurityContextRepository; 29 | this.loginWithTargetUrlAuthenticationEntryPoint = loginWithTargetUrlAuthenticationEntryPoint; 30 | this.redirectToOriginalUrlAuthenticationSuccessHandler = redirectToOriginalUrlAuthenticationSuccessHandler; 31 | this.inMemoryAuthenticationProvider = inMemoryAuthenticationProvider; 32 | } 33 | 34 | @Override 35 | protected void configure(HttpSecurity http) throws Exception { 36 | http 37 | // deactivate session creation 38 | .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) 39 | .and().csrf().disable() 40 | 41 | // store SecurityContext in Cookie / delete Cookie on logout 42 | .securityContext().securityContextRepository(cookieSecurityContextRepository) 43 | .and().logout().permitAll().deleteCookies(SignedUserInfoCookie.NAME) 44 | 45 | // deactivate RequestCache and append originally requested URL as query parameter to login form request 46 | .and().requestCache().disable() 47 | .exceptionHandling().authenticationEntryPoint(loginWithTargetUrlAuthenticationEntryPoint) 48 | 49 | // configure form-based login 50 | .and().formLogin() 51 | .loginPage(LOGIN_FORM_URL) 52 | // after successful login forward user to originally requested URL 53 | .successHandler(redirectToOriginalUrlAuthenticationSuccessHandler) 54 | 55 | .and().authorizeRequests() 56 | .antMatchers(LOGIN_FORM_URL).permitAll() 57 | .antMatchers("/**").authenticated(); 58 | } 59 | 60 | @Override 61 | protected void configure(AuthenticationManagerBuilder auth) throws Exception { 62 | auth.authenticationProvider(inMemoryAuthenticationProvider); 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | auth.cookie.hmac-key: "y.E@EA!FbtCwXYB-2v_n.!*xgzRqgtbq2d2_A_U!W2hubL@URHRzNP96WNPxEcXK" 2 | -------------------------------------------------------------------------------- /src/main/resources/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | Home 8 | 9 | 10 |

Hello [[${#httpServletRequest.remoteUser}]]

11 | 12 |
13 | 14 |
15 | 16 | Other 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/main/resources/templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | Login 8 | 9 | 10 |

Login

11 | 12 |
13 | Invalid username and password. 14 |
15 |
16 | You have been logged out. 17 |
18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 | Home 30 | Other 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/main/resources/templates/other.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | Other 8 | 9 | 10 |

Other

11 | 12 |
13 | 14 |
15 | 16 | Home 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/test/java/com/innoq/cookiebasedsessionapp/CookieSecurityContextRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package com.innoq.cookiebasedsessionapp; 2 | 3 | import org.junit.jupiter.api.BeforeEach; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.api.extension.ExtendWith; 6 | import org.mockito.ArgumentCaptor; 7 | import org.mockito.Captor; 8 | import org.mockito.Mock; 9 | import org.mockito.junit.jupiter.MockitoExtension; 10 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 11 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 12 | import org.springframework.security.core.context.SecurityContext; 13 | import org.springframework.security.web.context.HttpRequestResponseHolder; 14 | 15 | import javax.servlet.http.Cookie; 16 | import javax.servlet.http.HttpServletRequest; 17 | import javax.servlet.http.HttpServletResponse; 18 | import java.util.List; 19 | import java.util.Optional; 20 | 21 | import static org.assertj.core.api.Assertions.assertThat; 22 | import static org.assertj.core.api.Assertions.assertThatThrownBy; 23 | import static org.mockito.Mockito.*; 24 | 25 | @ExtendWith(MockitoExtension.class) 26 | public class CookieSecurityContextRepositoryTest { 27 | 28 | private static final String COOKIE_VALUE = "uid=ab1234&roles=USER|TESTER&colour=YELLOW&hmac=0k9BetqMZOijyq5gaM+2+sqCgDJOpSwHEgkyYwpfIyb5Zcnrsk/BqCWciGBEaYeGWTkMB1CEFJU0So0u8OTUUw=="; 29 | private static final String COOKIE_VALUE_WITHOUT_HMAC = "uid=ab1234&roles=USER|TESTER&colour=YELLOW"; 30 | private static final String COOKIE_VALUE_WITH_INVALID_HMAC = "uid=ab1234&roles=USER|TESTER&colour=YELLOW&hmac=invalid"; 31 | 32 | private static final String USERNAME = "ab1234"; 33 | private static final SimpleGrantedAuthority ROLE1 = new SimpleGrantedAuthority("USER"); 34 | private static final SimpleGrantedAuthority ROLE2 = new SimpleGrantedAuthority("TESTER"); 35 | private static final String COLOUR = "YELLOW"; 36 | 37 | private static final String COOKIE_HMAC_KEY = "y.E@EA!FbtCwXYB-2v_n.!*xgzRqgtbq2d2_A_U!W2hubL@URHRzNP96WNPxEcXK"; 38 | 39 | 40 | @Mock 41 | private HttpServletRequest request; 42 | @Mock 43 | private HttpServletResponse response; 44 | @Mock 45 | private Cookie userInfoCookie; 46 | 47 | @Mock 48 | private SecurityContext securityContext; 49 | @Mock 50 | private UsernamePasswordAuthenticationToken usernamePasswordAuthentication; 51 | @Mock 52 | private UserInfo userInfo; 53 | 54 | @Captor 55 | private ArgumentCaptor cookieCaptor; 56 | 57 | private HttpRequestResponseHolder requestResponseHolder; 58 | 59 | private final CookieSecurityContextRepository securityContextRepository = new CookieSecurityContextRepository(COOKIE_HMAC_KEY); 60 | 61 | @BeforeEach 62 | public void setupRequestResponseHolder() { 63 | requestResponseHolder = new HttpRequestResponseHolder(request, response); 64 | } 65 | 66 | @BeforeEach 67 | public void setupUserInfoCookie() { 68 | lenient().when(userInfoCookie.getName()).thenReturn(SignedUserInfoCookie.NAME); 69 | lenient().when(userInfoCookie.getValue()).thenReturn(COOKIE_VALUE); 70 | } 71 | 72 | @BeforeEach 73 | public void setupSecurityContext() { 74 | lenient().when(securityContext.getAuthentication()).thenReturn(usernamePasswordAuthentication); 75 | lenient().when(usernamePasswordAuthentication.getPrincipal()).thenReturn(userInfo); 76 | lenient().when(userInfo.getUsername()).thenReturn(USERNAME); 77 | lenient().when(userInfo.getAuthorities()).thenReturn(List.of(ROLE1, ROLE2)); 78 | lenient().when(userInfo.getColour()).thenReturn(Optional.of(COLOUR)); 79 | } 80 | 81 | @Test 82 | public void loadContext_noCookieInRequest() { 83 | SecurityContext securityContext = securityContextRepository.loadContext(requestResponseHolder); 84 | 85 | assertThat(securityContext).isNotNull(); 86 | assertThat(securityContext.getAuthentication()).isNull(); 87 | } 88 | 89 | @Test 90 | public void loadContext_cookieCompletelyFilled() { 91 | when(request.getCookies()).thenReturn(new Cookie[]{userInfoCookie}); 92 | SecurityContext securityContext = securityContextRepository.loadContext(requestResponseHolder); 93 | 94 | assertThat(securityContext).isNotNull(); 95 | assertThat(securityContext.getAuthentication()).isNotNull(); 96 | assertThat(securityContext.getAuthentication()).isInstanceOf(UsernamePasswordAuthenticationToken.class); 97 | 98 | UsernamePasswordAuthenticationToken usernamePasswordToken = (UsernamePasswordAuthenticationToken) securityContext.getAuthentication(); 99 | assertThat(usernamePasswordToken.isAuthenticated()).isTrue(); 100 | assertThat(usernamePasswordToken.getPrincipal()).isInstanceOf(UserInfo.class); 101 | 102 | UserInfo userInfo = (UserInfo) usernamePasswordToken.getPrincipal(); 103 | assertThat(userInfo.getUsername()).isEqualTo(USERNAME); 104 | } 105 | 106 | @Test 107 | public void loadContext_cookieWithoutHmac() { 108 | when(userInfoCookie.getValue()).thenReturn(COOKIE_VALUE_WITHOUT_HMAC); 109 | when(request.getCookies()).thenReturn(new Cookie[]{userInfoCookie}); 110 | 111 | assertThatThrownBy(() -> securityContextRepository.loadContext(requestResponseHolder)) 112 | .isInstanceOf(CookieVerificationFailedException.class); 113 | } 114 | 115 | @Test 116 | public void loadContext_cookieWithInvalidHmac() { 117 | when(userInfoCookie.getValue()).thenReturn(COOKIE_VALUE_WITH_INVALID_HMAC); 118 | when(request.getCookies()).thenReturn(new Cookie[]{userInfoCookie}); 119 | 120 | assertThatThrownBy(() -> securityContextRepository.loadContext(requestResponseHolder)) 121 | .isInstanceOf(CookieVerificationFailedException.class); 122 | } 123 | 124 | @Test 125 | public void containsContext_noCookieInRequest_returnsFalse() { 126 | assertThat(securityContextRepository.containsContext(request)).isFalse(); 127 | } 128 | 129 | @Test 130 | public void containsContext_cookieInRequest_returnsTrue() { 131 | when(request.getCookies()).thenReturn(new Cookie[]{userInfoCookie}); 132 | assertThat(securityContextRepository.containsContext(request)).isTrue(); 133 | } 134 | 135 | @Test 136 | public void saveContext_completelyFilledUserInfo() { 137 | // loadContext is called first to replace (plain) response with internal wrapper 138 | securityContextRepository.loadContext(requestResponseHolder); 139 | 140 | securityContextRepository.saveContext(securityContext, requestResponseHolder.getRequest(), requestResponseHolder.getResponse()); 141 | 142 | verify(response).addCookie(cookieCaptor.capture()); 143 | Cookie cookie = cookieCaptor.getValue(); 144 | assertThat(cookie.getName()).isEqualTo(SignedUserInfoCookie.NAME); 145 | assertThat(cookie.getValue()).isEqualTo(COOKIE_VALUE); 146 | } 147 | 148 | } 149 | -------------------------------------------------------------------------------- /src/test/java/com/innoq/cookiebasedsessionapp/LoginWithTargetUrlAuthenticationEntryPointTest.java: -------------------------------------------------------------------------------- 1 | package com.innoq.cookiebasedsessionapp; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.junit.jupiter.api.extension.ExtendWith; 5 | import org.mockito.Mock; 6 | import org.mockito.junit.jupiter.MockitoExtension; 7 | 8 | import javax.servlet.http.HttpServletRequest; 9 | import javax.servlet.http.HttpServletResponse; 10 | 11 | import static org.assertj.core.api.Assertions.assertThat; 12 | import static org.mockito.Mockito.when; 13 | 14 | @ExtendWith(MockitoExtension.class) 15 | public class LoginWithTargetUrlAuthenticationEntryPointTest { 16 | 17 | @Mock 18 | private HttpServletRequest request; 19 | @Mock 20 | private HttpServletResponse response; 21 | 22 | private LoginWithTargetUrlAuthenticationEntryPoint entryPoint = new LoginWithTargetUrlAuthenticationEntryPoint(); 23 | 24 | @Test 25 | public void appends_targetURL() { 26 | when(request.getRequestURI()).thenReturn("/original/url"); 27 | 28 | String url = entryPoint.determineUrlToUseForThisRequest(request, response, null); 29 | 30 | assertThat(url).isEqualTo("/login?target=/original/url"); 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/test/java/com/innoq/cookiebasedsessionapp/RedirectToOriginalUrlAuthenticationSuccessHandlerTest.java: -------------------------------------------------------------------------------- 1 | package com.innoq.cookiebasedsessionapp; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.junit.jupiter.api.extension.ExtendWith; 5 | import org.mockito.InjectMocks; 6 | import org.mockito.Mock; 7 | import org.mockito.junit.jupiter.MockitoExtension; 8 | import org.springframework.security.core.Authentication; 9 | 10 | import javax.servlet.ServletException; 11 | import javax.servlet.http.HttpServletRequest; 12 | import javax.servlet.http.HttpServletResponse; 13 | import java.io.IOException; 14 | 15 | import static org.assertj.core.api.Assertions.assertThat; 16 | import static org.mockito.Mockito.verify; 17 | import static org.mockito.Mockito.when; 18 | 19 | @ExtendWith(MockitoExtension.class) 20 | public class RedirectToOriginalUrlAuthenticationSuccessHandlerTest { 21 | 22 | @Mock 23 | private HttpServletRequest request; 24 | @Mock 25 | private HttpServletResponse response; 26 | @Mock 27 | private Authentication authentication; 28 | @Mock 29 | private UserInfo userInfo; 30 | 31 | @InjectMocks 32 | private RedirectToOriginalUrlAuthenticationSuccessHandler handler; 33 | 34 | @Test 35 | public void onAuthenticationSuccess_addsColourToUserInfo() throws IOException, ServletException { 36 | when(authentication.getPrincipal()).thenReturn(userInfo); 37 | when(request.getParameter("colour")).thenReturn("YELLOW"); 38 | 39 | handler.onAuthenticationSuccess(request, response, authentication); 40 | 41 | verify(userInfo).setColour("YELLOW"); 42 | } 43 | 44 | @Test 45 | public void determineTargetUrl_returnsTargetUrlFromRequest() { 46 | when(request.getParameter(WebSecurityConfig.TARGET_AFTER_SUCCESSFUL_LOGIN_PARAM)).thenReturn("/target"); 47 | 48 | var targetUrl = handler.determineTargetUrl(request, response, authentication); 49 | 50 | assertThat(targetUrl).isEqualTo("/target"); 51 | } 52 | 53 | @Test 54 | public void determineTargetUrl_suppressAbsolutUrls() { 55 | when(request.getParameter(WebSecurityConfig.TARGET_AFTER_SUCCESSFUL_LOGIN_PARAM)).thenReturn("http://www.google.de"); 56 | 57 | var targetUrl = handler.determineTargetUrl(request, response, authentication); 58 | 59 | assertThat(targetUrl).isEqualTo("/"); 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/test/java/com/innoq/cookiebasedsessionapp/SignedUserInfoCookieTest.java: -------------------------------------------------------------------------------- 1 | package com.innoq.cookiebasedsessionapp; 2 | 3 | import org.junit.jupiter.api.BeforeEach; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.api.extension.ExtendWith; 6 | import org.mockito.Mock; 7 | import org.mockito.junit.jupiter.MockitoExtension; 8 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 9 | 10 | import javax.servlet.http.Cookie; 11 | import java.util.List; 12 | import java.util.Optional; 13 | 14 | import static org.assertj.core.api.Assertions.assertThat; 15 | import static org.assertj.core.api.Assertions.assertThatThrownBy; 16 | import static org.mockito.Mockito.lenient; 17 | import static org.mockito.Mockito.when; 18 | 19 | @ExtendWith(MockitoExtension.class) 20 | public class SignedUserInfoCookieTest { 21 | 22 | private static final String COOKIE_VALUE_WITH_HMAC = "uid=ab1234&roles=USER|TESTER&colour=YELLOW&hmac=0k9BetqMZOijyq5gaM+2+sqCgDJOpSwHEgkyYwpfIyb5Zcnrsk/BqCWciGBEaYeGWTkMB1CEFJU0So0u8OTUUw=="; 23 | public static final String COOKIE_VALUE_WITHOUT_ROLES = "uid=ab1234&roles=&colour=YELLOW&hmac=w51eeYpz+/lbAOA7KUZC43UeF0nUZZxcKpJFRrh7CyhsR+EE77AaRSJKsq0HxNgbxmuLxsstkV/JiFawwnv47g=="; 24 | public static final String COOKIE_VALUE_WITHOUT_COLOUR = "uid=ab1234&roles=USER|TESTER&hmac=wRYQmJZQ3JLnOiuYLV6ETG0kmz0H+7leJvvl1m14Pb5LP/FupJHdrIhzKc1gApenSNSCSvE20y9+oxwRfvYy8g=="; 25 | private static final String COOKIE_VALUE_WITHOUT_ROLES_AND_COLOUR = "uid=ab1234&roles=&hmac=Tpe2mlTIn0ZzHWnXVtrmDrcEdoLHzOwoeTRyMCpmJkDsawRjfyWgMR6Xc0Qwv79XNoN3o3/QWPcDQwZiK6KY9w=="; 26 | private static final String COOKIE_VALUE_WITHOUT_HMAC = "uid=ab1234&roles=USER|TESTER&colour=YELLOW"; 27 | private static final String COOKIE_VALUE_WITH_INVALID_HMAC = "uid=ab1234&roles=USER|TESTER&colour=YELLOW&hmac=invalid"; 28 | 29 | private static final String USERNAME = "ab1234"; 30 | private static final SimpleGrantedAuthority ROLE1 = new SimpleGrantedAuthority("USER"); 31 | private static final SimpleGrantedAuthority ROLE2 = new SimpleGrantedAuthority("TESTER"); 32 | private static final String COLOUR = "YELLOW"; 33 | 34 | private static final String SECRET_KEY = "y.E@EA!FbtCwXYB-2v_n.!*xgzRqgtbq2d2_A_U!W2hubL@URHRzNP96WNPxEcXK"; 35 | private static final String HMAC = "0k9BetqMZOijyq5gaM+2+sqCgDJOpSwHEgkyYwpfIyb5Zcnrsk/BqCWciGBEaYeGWTkMB1CEFJU0So0u8OTUUw=="; 36 | 37 | @Mock 38 | private UserInfo userInfo; 39 | @Mock 40 | private Cookie cookie; 41 | 42 | @BeforeEach 43 | public void setupUserInfo() { 44 | lenient().when(userInfo.getUsername()).thenReturn(USERNAME); 45 | lenient().when(userInfo.getAuthorities()).thenReturn(List.of(ROLE1, ROLE2)); 46 | lenient().when(userInfo.getColour()).thenReturn(Optional.of(COLOUR)); 47 | } 48 | 49 | @BeforeEach 50 | public void setupCookie() { 51 | lenient().when(cookie.getName()).thenReturn(SignedUserInfoCookie.NAME); 52 | lenient().when(cookie.getValue()).thenReturn(COOKIE_VALUE_WITH_HMAC); 53 | } 54 | 55 | @Test 56 | public void create_fromUserInfo() { 57 | SignedUserInfoCookie signedUserInfoCookie = new SignedUserInfoCookie(userInfo, SECRET_KEY); 58 | 59 | assertThat(signedUserInfoCookie.getValue()).isEqualTo(COOKIE_VALUE_WITH_HMAC); 60 | } 61 | 62 | @Test 63 | public void create_fromUserInfo_withoutRoles() { 64 | when(userInfo.getAuthorities()).thenReturn(List.of()); 65 | 66 | SignedUserInfoCookie signedUserInfoCookie = new SignedUserInfoCookie(userInfo, SECRET_KEY); 67 | 68 | assertThat(signedUserInfoCookie.getValue()).isEqualTo(COOKIE_VALUE_WITHOUT_ROLES); 69 | } 70 | 71 | @Test 72 | public void create_fromUserInfo_withoutColour() { 73 | when(userInfo.getColour()).thenReturn(Optional.empty()); 74 | 75 | SignedUserInfoCookie signedUserInfoCookie = new SignedUserInfoCookie(userInfo, SECRET_KEY); 76 | 77 | assertThat(signedUserInfoCookie.getValue()).isEqualTo(COOKIE_VALUE_WITHOUT_COLOUR); 78 | } 79 | 80 | @Test 81 | public void create_fromBenutzer_ohneRollenLandUndMarke() { 82 | when(userInfo.getAuthorities()).thenReturn(List.of()); 83 | when(userInfo.getColour()).thenReturn(Optional.empty()); 84 | 85 | SignedUserInfoCookie signedUserInfoCookie = new SignedUserInfoCookie(userInfo, SECRET_KEY); 86 | 87 | assertThat(signedUserInfoCookie.getValue()).isEqualTo(COOKIE_VALUE_WITHOUT_ROLES_AND_COLOUR); 88 | } 89 | 90 | @Test 91 | public void create_fromCookie() { 92 | SignedUserInfoCookie signedUserInfoCookie = new SignedUserInfoCookie(cookie, SECRET_KEY); 93 | 94 | assertThat(signedUserInfoCookie.getUsername()).isEqualTo(USERNAME); 95 | assertThat(signedUserInfoCookie.getRoles()).containsExactlyInAnyOrder(ROLE1.getAuthority(), ROLE2.getAuthority()); 96 | assertThat(signedUserInfoCookie.getColour()).isEqualTo(COLOUR); 97 | assertThat(signedUserInfoCookie.getHmac()).isEqualTo(HMAC); 98 | } 99 | 100 | @Test 101 | public void getUserInfo_fromCookie() { 102 | UserInfo userInfo = new SignedUserInfoCookie(cookie, SECRET_KEY).getUserInfo(); 103 | 104 | assertThat(userInfo.getUsername()).isEqualTo(USERNAME); 105 | assertThat(userInfo.getAuthorities()).describedAs("roles").containsExactlyInAnyOrder(ROLE1, ROLE2); 106 | assertThat(userInfo.getColour()).isPresent().hasValue(COLOUR); 107 | } 108 | 109 | @Test 110 | public void getUserInfo_fromCookie_withoutRoles() { 111 | when(cookie.getValue()).thenReturn(COOKIE_VALUE_WITHOUT_ROLES); 112 | 113 | UserInfo userInfo = new SignedUserInfoCookie(cookie, SECRET_KEY).getUserInfo(); 114 | 115 | assertThat(userInfo.getAuthorities()).isEmpty(); 116 | } 117 | 118 | @Test 119 | public void getUserInfo_fromCookie_withoutColour() { 120 | when(cookie.getValue()).thenReturn(COOKIE_VALUE_WITHOUT_COLOUR); 121 | 122 | UserInfo userInfo = new SignedUserInfoCookie(cookie, SECRET_KEY).getUserInfo(); 123 | 124 | assertThat(userInfo.getColour()).isEmpty(); 125 | } 126 | 127 | @Test 128 | public void getUserInfo_fromCookie_withoutRolesAndColour() { 129 | when(cookie.getValue()).thenReturn(COOKIE_VALUE_WITHOUT_ROLES_AND_COLOUR); 130 | 131 | UserInfo userInfo = new SignedUserInfoCookie(cookie, SECRET_KEY).getUserInfo(); 132 | 133 | assertThat(userInfo.getAuthorities()).isEmpty(); 134 | assertThat(userInfo.getColour()).isEmpty(); 135 | } 136 | 137 | @Test 138 | public void getUserInfo_fromCookie_missingSignature() { 139 | when(cookie.getValue()).thenReturn(COOKIE_VALUE_WITHOUT_HMAC); 140 | 141 | assertThatThrownBy(() -> new SignedUserInfoCookie(cookie, SECRET_KEY)) 142 | .isInstanceOf(CookieVerificationFailedException.class); 143 | } 144 | 145 | @Test 146 | public void getUserInfo_fromCookie_invalidSignature() { 147 | when(cookie.getValue()).thenReturn(COOKIE_VALUE_WITH_INVALID_HMAC); 148 | 149 | assertThatThrownBy(() -> new SignedUserInfoCookie(cookie, SECRET_KEY)) 150 | .isInstanceOf(CookieVerificationFailedException.class); 151 | } 152 | 153 | } 154 | --------------------------------------------------------------------------------