├── .gitignore
├── NOTICE
├── src
├── main
│ ├── resources
│ │ ├── application.yml
│ │ └── templates
│ │ │ ├── other.html
│ │ │ ├── index.html
│ │ │ └── login.html
│ └── java
│ │ └── com
│ │ └── innoq
│ │ └── cookiebasedsessionapp
│ │ ├── CookieVerificationFailedException.java
│ │ ├── CookieBasedSessionSpringBootApplication.java
│ │ ├── MvcConfig.java
│ │ ├── LoginWithTargetUrlAuthenticationEntryPoint.java
│ │ ├── InMemoryAuthenticationProvider.java
│ │ ├── RedirectToOriginalUrlAuthenticationSuccessHandler.java
│ │ ├── UserInfo.java
│ │ ├── WebSecurityConfig.java
│ │ ├── CookieSecurityContextRepository.java
│ │ └── SignedUserInfoCookie.java
└── test
│ └── java
│ └── com
│ └── innoq
│ └── cookiebasedsessionapp
│ ├── LoginWithTargetUrlAuthenticationEntryPointTest.java
│ ├── RedirectToOriginalUrlAuthenticationSuccessHandlerTest.java
│ ├── CookieSecurityContextRepositoryTest.java
│ └── SignedUserInfoCookieTest.java
├── .editorconfig
├── README.md
├── pom.xml
└── LICENSE.txt
/.gitignore:
--------------------------------------------------------------------------------
1 | target/
2 | *.iml
3 | .idea/
4 |
--------------------------------------------------------------------------------
/NOTICE:
--------------------------------------------------------------------------------
1 | cookie-based-session-springboot-app
2 | Copyright (C) 2020 innoQ Deutschland GmbH
3 |
--------------------------------------------------------------------------------
/src/main/resources/application.yml:
--------------------------------------------------------------------------------
1 | auth.cookie.hmac-key: "y.E@EA!FbtCwXYB-2v_n.!*xgzRqgtbq2d2_A_U!W2hubL@URHRzNP96WNPxEcXK"
2 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/resources/templates/other.html:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 | Other
8 |
9 |
10 | Other
11 |
12 |
15 |
16 | Home
17 |
18 |
19 |
--------------------------------------------------------------------------------
/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/resources/templates/index.html:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 | Home
8 |
9 |
10 | Hello [[${#httpServletRequest.remoteUser}]]
11 |
12 |
15 |
16 | Other
17 |
18 |
19 |
--------------------------------------------------------------------------------
/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/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 |
28 |
29 | Home
30 | Other
31 |
32 |
33 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------