Skip to content

Commit ae25da8

Browse files
jekutzschemad-nuts
andauthored
feat: The client is now a bit more secure against attacks and authentication token (JWT) stealing. For this, the JWT is now transferred and processed in HTTP-only cookies. In this context, XSRF protection with XSRF-TOKEN cookies has also been enabled.
Beside the `security.jwt.sharedSecret` the following values are configurable now: - `security.jwt.expirationTime` (expiration time of the JWT) - `security.jwt.cookieName` (name of the HTTP-Only cookie) - `security.jwt.setSecure` (the boolean value for the cookie parameter `Secure` ) - `security.jwt.same-site` (`Strict` or `Lax` as value for the cookie parameter `SameSite`) FE: Mock authentication flow and user role definition without using a token in the request header (mockAPIServer). We have tried to fix CORS errors with the e2e tests, however there are still issues that need to be investigated and fixed later: - Changes NGINX template to avoid network errors - makes sameSite parameter of the cookies configurable Co-authored-by: andreas.kausler <[email protected]> PR #802
1 parent 863a24c commit ae25da8

File tree

24 files changed

+356
-139
lines changed

24 files changed

+356
-139
lines changed

infrastructure/dev/nginx/iris-client.conf.template

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,11 @@ server {
6464
set $cors_header "";
6565
set $cors_method "";
6666
set $cors_maxage "";
67-
if ( $http_origin ~* ((https:\/\/iris.staging.iris-gateway\.de|http:\/\/localhost:8080)$)) {
67+
if ( $http_origin ~* ((https:\/\/iris.staging.iris-gateway\.de|https:\/\/client.dev.iris-gateway\.de|http:\/\/localhost:8080|http:\/\/localhost:8081)$)) {
6868
set $cors_origin $http_origin;
6969
set $cors_cred true;
70-
set $cors_header "*";
71-
set $cors_method "*";
70+
set $cors_header $http_access_control_request_headers;
71+
set $cors_method $http_access_control_request_method;
7272
set $cors_maxage "86400";
7373
}
7474

iris-client-bff/src/main/java/iris/client_bff/auth/db/DbAuthSecurityConfig.java

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import org.springframework.context.annotation.Configuration;
1818
import org.springframework.core.convert.converter.Converter;
1919
import org.springframework.core.env.Environment;
20+
import org.springframework.http.HttpHeaders;
2021
import org.springframework.http.HttpMethod;
2122
import org.springframework.http.HttpStatus;
2223
import org.springframework.security.authentication.AbstractAuthenticationToken;
@@ -32,11 +33,11 @@
3233
import org.springframework.security.oauth2.jwt.Jwt;
3334
import org.springframework.security.oauth2.jwt.JwtDecoder;
3435
import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
35-
import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver;
3636
import org.springframework.security.web.AuthenticationEntryPoint;
3737
import org.springframework.security.web.access.AccessDeniedHandler;
3838
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler;
3939
import org.springframework.security.web.authentication.logout.LogoutHandler;
40+
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
4041

4142
@Configuration
4243
@ConditionalOnProperty(
@@ -46,6 +47,8 @@
4647
@RequiredArgsConstructor
4748
public class DbAuthSecurityConfig extends WebSecurityConfigurerAdapter {
4849

50+
private static final String LOGIN = "/login";
51+
4952
private static final String USER_NOT_FOUND = "User: %s, not found";
5053

5154
private static final String[] SWAGGER_WHITELIST = {
@@ -73,7 +76,9 @@ protected void configure(HttpSecurity http) throws Exception {
7376
}
7477

7578
http.cors().and()
76-
.csrf().disable()
79+
.csrf(it -> it
80+
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
81+
.ignoringAntMatchers(LOGIN, DATA_SUBMISSION_ENDPOINT, DATA_SUBMISSION_ENDPOINT_WITH_SLASH))
7782
.oauth2ResourceServer(it -> it
7883
.bearerTokenResolver(bearerTokenResolver())
7984
.authenticationEntryPoint(authenticationEntryPoint)
@@ -87,7 +92,7 @@ protected void configure(HttpSecurity http) throws Exception {
8792
.requestMatchers(EndpointRequest.toAnyEndpoint()).hasAuthority(UserRole.ADMIN.name())
8893
.antMatchers(HttpMethod.POST, DATA_SUBMISSION_ENDPOINT).permitAll()
8994
.antMatchers(HttpMethod.POST, DATA_SUBMISSION_ENDPOINT_WITH_SLASH).permitAll()
90-
.antMatchers("/login", "/error").permitAll()
95+
.antMatchers(LOGIN, "/error").permitAll()
9196
.anyRequest().authenticated())
9297
.logout(it -> it
9398
.logoutUrl("/user/logout")
@@ -121,7 +126,7 @@ public UserDetailsService userDetailsServiceBean() {
121126

122127
@Bean
123128
BearerTokenResolver bearerTokenResolver() {
124-
return new DefaultBearerTokenResolver();
129+
return jwtService.createTokenResolver();
125130
}
126131

127132
@Bean
@@ -136,11 +141,12 @@ Converter<Jwt, AbstractAuthenticationToken> authenticationConverter() {
136141

137142
LogoutHandler logoutHandler() {
138143

139-
return (req, __, ___) -> Try.success(req)
144+
return (req, res, ___) -> Try.success(req)
140145
.map(bearerTokenResolver()::resolve)
141146
.map(jwtDecoder()::decode)
142147
.map(Jwt::getSubject)
143-
.onSuccess(jwtService::invalidateTokensOfUser);
148+
.onSuccess(jwtService::invalidateTokensOfUser)
149+
.onSuccess(__ -> res.addHeader(HttpHeaders.SET_COOKIE, jwtService.createCleanJwtCookie().toString()));
144150
}
145151

146152
private UserAccount findUser(String username) {

iris-client-bff/src/main/java/iris/client_bff/auth/db/jwt/JWTService.java

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,28 @@
11
package iris.client_bff.auth.db.jwt;
22

33
import static iris.client_bff.auth.db.jwt.JwtConstants.*;
4+
import static java.util.Optional.*;
45

56
import lombok.RequiredArgsConstructor;
67
import lombok.Value;
78
import lombok.extern.slf4j.Slf4j;
89

910
import java.nio.charset.StandardCharsets;
11+
import java.time.Duration;
1012
import java.time.Instant;
1113
import java.util.Date;
1214
import java.util.List;
1315

1416
import javax.crypto.spec.SecretKeySpec;
17+
import javax.servlet.http.Cookie;
1518
import javax.validation.constraints.NotBlank;
19+
import javax.validation.constraints.NotNull;
1620

1721
import org.apache.commons.codec.digest.DigestUtils;
1822
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
1923
import org.springframework.boot.context.properties.ConfigurationProperties;
2024
import org.springframework.boot.context.properties.ConstructorBinding;
25+
import org.springframework.http.ResponseCookie;
2126
import org.springframework.security.core.userdetails.UserDetails;
2227
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
2328
import org.springframework.security.oauth2.core.OAuth2Error;
@@ -29,8 +34,10 @@
2934
import org.springframework.security.oauth2.jwt.JwtDecoder;
3035
import org.springframework.security.oauth2.jwt.JwtValidators;
3136
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
37+
import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
3238
import org.springframework.stereotype.Service;
3339
import org.springframework.validation.annotation.Validated;
40+
import org.springframework.web.util.WebUtils;
3441

3542
import com.auth0.jwt.JWT;
3643
import com.auth0.jwt.JWTCreator.Builder;
@@ -48,6 +55,13 @@ public class JWTService {
4855

4956
private final JWTService.Properties jwtProperties;
5057

58+
public BearerTokenResolver createTokenResolver() {
59+
60+
return request -> ofNullable(WebUtils.getCookie(request, jwtProperties.getCookieName()))
61+
.map(Cookie::getValue)
62+
.orElse(null);
63+
}
64+
5165
public JwtDecoder createJwtDecoder() {
5266

5367
var algorithmName = getAlgorithm().getName();
@@ -62,25 +76,22 @@ public JwtDecoder createJwtDecoder() {
6276
return decoder;
6377
}
6478

65-
public String createToken(UserDetails user) {
66-
67-
var username = user.getUsername();
68-
69-
// By convention we expect that there exists only one authority and it represents the role
70-
var role = user.getAuthorities().iterator().next().getAuthority();
79+
public ResponseCookie createJwtCookie(UserDetails user) {
7180

72-
var issuedAt = Instant.now();
73-
var expirationTime = issuedAt.plus(EXPIRATION_TIME);
81+
var jwt = createToken(user);
7482

75-
var token = sign(JWT.create()
76-
.withSubject(username)
77-
.withClaim(JWT_CLAIM_USER_ROLE, role)
78-
.withIssuedAt(Date.from(issuedAt))
79-
.withExpiresAt(Date.from(expirationTime)));
80-
81-
saveToken(token, username, expirationTime, issuedAt);
83+
return ResponseCookie.from(jwtProperties.getCookieName(), jwt)
84+
.maxAge(jwtProperties.getExpirationTime().toSeconds())
85+
.secure(jwtProperties.isSetSecure())
86+
.httpOnly(true)
87+
.sameSite(jwtProperties.getSameSiteStr())
88+
.build();
89+
}
8290

83-
return token;
91+
public ResponseCookie createCleanJwtCookie() {
92+
// Without setting maxAge >= 0, we would create a session cookie.
93+
// Setting it to 0 will delete the cookie.
94+
return ResponseCookie.from(jwtProperties.getCookieName(), null).maxAge(0).build();
8495
}
8596

8697
public boolean isTokenWhitelisted(String token) {
@@ -118,6 +129,27 @@ private OAuth2TokenValidatorResult isTokenWhitelisted(Jwt jwt) {
118129
return OAuth2TokenValidatorResult.failure(error);
119130
}
120131

132+
private String createToken(UserDetails user) {
133+
134+
var username = user.getUsername();
135+
136+
// By convention we expect that there exists only one authority and it represents the role
137+
var role = user.getAuthorities().iterator().next().getAuthority();
138+
139+
var issuedAt = Instant.now();
140+
var expirationTime = issuedAt.plus(jwtProperties.getExpirationTime());
141+
142+
var token = sign(JWT.create()
143+
.withSubject(username)
144+
.withClaim(JWT_CLAIM_USER_ROLE, role)
145+
.withIssuedAt(Date.from(issuedAt))
146+
.withExpiresAt(Date.from(expirationTime)));
147+
148+
saveToken(token, username, expirationTime, issuedAt);
149+
150+
return token;
151+
}
152+
121153
private String sign(Builder builder) {
122154
return builder.sign(getAlgorithm());
123155
}
@@ -143,6 +175,25 @@ private String hashToken(String jwt) {
143175
@Value
144176
static class Properties {
145177

146-
private @NotBlank String sharedSecret;
178+
@NotBlank
179+
String sharedSecret;
180+
181+
@NotNull
182+
Duration expirationTime;
183+
184+
@NotBlank
185+
String cookieName;
186+
187+
boolean setSecure;
188+
189+
SameSite sameSite;
190+
191+
String getSameSiteStr() {
192+
return getSameSite().name();
193+
}
194+
195+
enum SameSite {
196+
Strict, Lax
197+
}
147198
}
148199
}

iris-client-bff/src/main/java/iris/client_bff/auth/db/jwt/JwtConstants.java

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,8 @@
33
import lombok.AccessLevel;
44
import lombok.NoArgsConstructor;
55

6-
import java.time.Duration;
7-
86
@NoArgsConstructor(access = AccessLevel.PRIVATE)
97
class JwtConstants {
108

11-
public static final Duration EXPIRATION_TIME = Duration.ofHours(1);
129
public static final String JWT_CLAIM_USER_ROLE = "role";
1310
}

iris-client-bff/src/main/java/iris/client_bff/auth/db/web/LoginController.java

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,6 @@
2424
import org.springframework.web.bind.annotation.RequestMapping;
2525
import org.springframework.web.bind.annotation.RestController;
2626

27-
import com.fasterxml.jackson.core.exc.StreamReadException;
28-
import com.fasterxml.jackson.databind.DatabindException;
29-
3027
/**
3128
* @author Jens Kutzsche
3229
*/
@@ -39,16 +36,12 @@
3936
@Slf4j
4037
public class LoginController {
4138

42-
public static final String AUTHENTICATION_INFO = "Authentication-Info";
43-
public static final String BEARER_TOKEN_PREFIX = "Bearer ";
44-
4539
private final AuthenticationManager authManager;
4640
private final LoginAttemptsService loginAttempts;
4741
private final JWTService jwtService;
4842

4943
@PostMapping("/login")
50-
public ResponseEntity<?> login(@RequestBody LoginRequestDto login, HttpServletRequest req)
51-
throws StreamReadException, DatabindException, IOException {
44+
public ResponseEntity<?> login(@RequestBody LoginRequestDto login, HttpServletRequest req) throws IOException {
5245

5346
log.debug("Login request from remote address: " + LogHelper.obfuscateLastThree(req.getRemoteAddr()));
5447

@@ -66,20 +59,11 @@ public ResponseEntity<?> login(@RequestBody LoginRequestDto login, HttpServletRe
6659

6760
var user = (UserDetails) authManager.authenticate(authToken).getPrincipal();
6861

69-
var token = jwtService.createToken(user);
62+
var jwtCookie = jwtService.createJwtCookie(user);
7063

71-
var headers = new HttpHeaders();
72-
headers.add(AUTHENTICATION_INFO, BEARER_TOKEN_PREFIX + token);
73-
headers.add(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, AUTHENTICATION_INFO);
74-
75-
return ResponseEntity.ok().headers(headers).body(new LoginResponseDto(token));
64+
return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, jwtCookie.toString()).build();
7665
}
7766

78-
// @ExceptionHandler
79-
// ResponseEntity<?> heandleLockedException(LockedException e) {
80-
// return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
81-
// }
82-
8367
static record LoginRequestDto(@NotBlank String userName, @NotBlank String password) {}
8468

8569
static record LoginResponseDto(String token) {}

iris-client-bff/src/main/java/iris/client_bff/config/CORSConfig.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,10 @@ public WebMvcConfigurer configureCorsForLocalDev() {
3838
public void addCorsMappings(CorsRegistry registry) {
3939
registry
4040
.addMapping("/**")
41-
.allowedOrigins("*")
41+
.allowedOriginPatterns("*")
4242
.allowedMethods("*")
43-
.allowedHeaders("*");
43+
.allowedHeaders("*")
44+
.allowCredentials(true);
4445
}
4546
};
4647
}

iris-client-bff/src/main/java/iris/client_bff/core/web/error/GlobalFilterExceptionHandler.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package iris.client_bff.core.web.error;
22

3+
import iris.client_bff.auth.db.jwt.JWTService;
34
import lombok.RequiredArgsConstructor;
45

56
import java.io.IOException;
@@ -8,6 +9,7 @@
89
import javax.servlet.http.HttpServletRequest;
910
import javax.servlet.http.HttpServletResponse;
1011

12+
import org.springframework.http.HttpHeaders;
1113
import org.springframework.security.access.AccessDeniedException;
1214
import org.springframework.security.core.AuthenticationException;
1315
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
@@ -25,6 +27,8 @@ class GlobalFilterExceptionHandler implements AuthenticationEntryPoint, AccessDe
2527

2628
private final IrisErrorAttributes errorAttributes;
2729

30+
private final JWTService jwtService;
31+
2832
/**
2933
* handle authentication error
3034
*/
@@ -33,6 +37,10 @@ public void commence(HttpServletRequest request, HttpServletResponse response, A
3337
throws IOException, ServletException {
3438

3539
authenticationEntryPoint.commence(request, response, authException);
40+
41+
// If there is an existing http only cookie containing an invalid token, it has to be deleted.
42+
// Otherwise, the user won't be able to login, because the invalid token gets rejected with every login request.
43+
response.addHeader(HttpHeaders.SET_COOKIE, jwtService.createCleanJwtCookie().toString());
3644
response.sendError(response.getStatus());
3745

3846
errorAttributes.resolveException(request, response, authenticationEntryPoint, authException);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
security.auth=db
22
security.auth.db.admin-user-name=admin
33
security.auth.db.admin-user-password=admin
4+
5+
security.jwt.set-secure=false

iris-client-bff/src/main/resources/application.properties

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ spring.jackson.default-property-inclusion=NON_ABSENT
7070
springdoc.api-docs.enabled=false
7171

7272
security.jwt.shared-secret=${random.value}${random.value}
73+
security.jwt.expiration-time=1h
74+
security.jwt.cookie-name=IRIS_JWT
75+
security.jwt.set-secure=true
76+
security.jwt.same-site=Strict
7377
# Durations >0 like descripted in https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.external-config.typesafe-configuration-properties.conversion
7478
security.login.attempts.first-waiting-time=3s
7579
security.login.attempts.ignore-old-attempts-after=1h

0 commit comments

Comments
 (0)