diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a2936c4453..e7e9a32921 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ managed-nimbus-jose-jwt = "9.41.2" managed-jjwt = "0.12.6" -micronaut = "4.7.0" +micronaut = "4.7.1" micronaut-platform = "4.6.3" micronaut-docs = "2.0.0" diff --git a/security-csrf/build.gradle.kts b/security-csrf/build.gradle.kts new file mode 100644 index 0000000000..ba286f32ac --- /dev/null +++ b/security-csrf/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + id("io.micronaut.build.internal.security-module") +} + +dependencies { + api(projects.micronautSecurity) + compileOnly(mn.micronaut.http.server) + compileOnly(projects.micronautSecuritySession) + testAnnotationProcessor(mn.micronaut.inject.java) + testImplementation(mnTest.micronaut.test.junit5) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(mnLogging.logback.classic) + testImplementation(mn.micronaut.http.server.netty) + testImplementation(mn.micronaut.http.client) + testAnnotationProcessor(mnSerde.micronaut.serde.processor) + testImplementation(mnSerde.micronaut.serde.jackson) + testImplementation(projects.testSuiteUtilsSecurity) + testImplementation(projects.micronautSecurityJwt) + testImplementation(projects.micronautSecuritySession) +} + +tasks.withType { + useJUnitPlatform() +} + +micronautBuild { + binaryCompatibility.enabled = false +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/CsrfConfiguration.java b/security-csrf/src/main/java/io/micronaut/security/csrf/CsrfConfiguration.java new file mode 100644 index 0000000000..7896e27517 --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/CsrfConfiguration.java @@ -0,0 +1,65 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.csrf; + +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.util.Toggleable; +import io.micronaut.http.cookie.CookieConfiguration; +import io.micronaut.security.config.SecurityConfigurationProperties; + +/** + * CSRF Configuration. + * @author Sergio del Amo + * @since 4.11.0 + */ +public interface CsrfConfiguration extends CookieConfiguration, Toggleable { + String PREFIX = SecurityConfigurationProperties.PREFIX + ".csrf"; + + /** + * + * @return Random value's size in bytes. The random value used is used to build a CSRF Token. + */ + int getRandomValueSize(); + + /** + * + * @return The Secret Key that is used to calculate an HMAC as part of a CSRF token generation. + */ + @Nullable + String getSecretKey(); + + /** + * HTTP Header name to look for the CSRF token. It is recommended to use a custom request header. By using a custom HTTP Header name, it will not be possible to send them cross-origin without a permissive CORS implementation. + * @return HTTP Header name to look for the CSRF token. + */ + @NonNull + String getHeaderName(); + + /** + * + * @return Key to look for the CSRF token in an HTTP Session. + */ + @NonNull + String getHttpSessionName(); + + /** + * + * @return Field name in a form url encoded submission to look for the CSRF token. + */ + @NonNull + String getFieldName(); +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/CsrfConfigurationProperties.java b/security-csrf/src/main/java/io/micronaut/security/csrf/CsrfConfigurationProperties.java new file mode 100644 index 0000000000..4dc974b3ef --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/CsrfConfigurationProperties.java @@ -0,0 +1,270 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.csrf; + +import io.micronaut.context.annotation.ConfigurationProperties; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.http.cookie.SameSite; +import io.micronaut.security.token.generator.AccessTokenConfigurationProperties; + +import java.time.Duration; +import java.time.temporal.TemporalAmount; +import java.util.Optional; + +@Internal +@ConfigurationProperties(CsrfConfiguration.PREFIX) +final class CsrfConfigurationProperties implements CsrfConfiguration { + /** + * The default HTTP Header name. + */ + @SuppressWarnings("WeakerAccess") + public static final String DEFAULT_HTTP_HEADER_NAME = "X-CSRF-TOKEN"; + + /** + * The default fieldName. + */ + @SuppressWarnings("WeakerAccess") + public static final String DEFAULT_FIELD_NAME = "csrfToken"; + + /** + * The default cookie name.. + * @see Using Cookies with Host Prefixes to Identify Origins + */ + @SuppressWarnings("WeakerAccess") + public static final String DEFAULT_COOKIE_NAME = "__Host-csrfToken"; + + /** + * The default HTTP Session name. + */ + @SuppressWarnings("WeakerAccess") + public static final String DEFAULT_HTTP_SESSION_NAME = "csrfToken"; + + /** + * The default Same Site Configuration. + */ + @SuppressWarnings("WeakerAccess") + public static final SameSite DEFAULT_SAME_SITE = SameSite.Strict; + + public static final int DEFAULT_RANDOM_VALUE_SIZE = 16; + + public static final boolean DEFAULT_ENABLED = true; + + private static final boolean DEFAULT_HTTPONLY = true; + private static final String DEFAULT_COOKIEPATH = "/"; + private static final Boolean DEFAULT_SECURE = true; + private static final Duration DEFAULT_MAX_AGE = Duration.ofSeconds(AccessTokenConfigurationProperties.DEFAULT_EXPIRATION); + + private boolean enabled = DEFAULT_ENABLED; + private String headerName = DEFAULT_HTTP_HEADER_NAME; + private String fieldName = DEFAULT_FIELD_NAME; + private int randomValueSize = DEFAULT_RANDOM_VALUE_SIZE; + private String httpSessionName = DEFAULT_HTTP_SESSION_NAME; + + @Nullable + private String cookieDomain; + + private Boolean cookieSecure = DEFAULT_SECURE; + private String cookiePath = DEFAULT_COOKIEPATH; + private Boolean cookieHttpOnly = DEFAULT_HTTPONLY; + private Duration cookieMaxAge = DEFAULT_MAX_AGE; + private String cookieName = DEFAULT_COOKIE_NAME; + private SameSite sameSite = DEFAULT_SAME_SITE; + + @Nullable + private String signatureKey; + + @Override + @Nullable + public String getSecretKey() { + return signatureKey; + } + + /** + * The Secret Key that is used to calculate an HMAC as part of a CSRF token generation. Default Value `null`. + * @param signatureKey The Secret Key that is used to calculate an HMAC as part of a CSRF token generation. + */ + public void setSignatureKey(@Nullable String signatureKey) { + this.signatureKey = signatureKey; + } + + @Override + @NonNull + public String getHttpSessionName() { + return httpSessionName; + } + + /** + * Key to look for the CSRF token in an HTTP Session. Default Value: {@value #DEFAULT_HTTP_SESSION_NAME}. + * @param httpSessionName Key to look for the CSRF token in an HTTP Session. + */ + public void setHttpSessionName(@NonNull String httpSessionName) { + this.httpSessionName = httpSessionName; + } + + @Override + public int getRandomValueSize() { + return randomValueSize; + } + + /** + * Random value's size in bytes. The random value used is used to build a CSRF Token. Default Value: {@value #DEFAULT_RANDOM_VALUE_SIZE}. + * @param randomValueSize Random CSRF Token size in bytes. + */ + public void setRandomValueSize(int randomValueSize) { + this.randomValueSize = randomValueSize; + } + + @Override + @NonNull + public String getHeaderName() { + return headerName; + } + + /** + * HTTP Header name to look for the CSRF token. Default Value: {@value #DEFAULT_HTTP_HEADER_NAME}. + * @param headerName HTTP Header name to look for the CSRF token. + */ + public void setHeaderName(@NonNull String headerName) { + this.headerName = headerName; + } + + @Override + @NonNull + public String getFieldName() { + return fieldName; + } + + /** + * Field name in a form url encoded submission to look for the CSRF token. Default Value: {@value #DEFAULT_FIELD_NAME}. + * @param fieldName Field name in a form url encoded submission to look for the CSRF token. + */ + public void setFieldName(@NonNull String fieldName) { + this.fieldName = fieldName; + } + + @Override + public boolean isEnabled() { + return enabled; + } + + /** + * Whether the CSRF integration is enabled. Default value {@value #DEFAULT_ENABLED}. + * @param enabled Whether the CSRF integration is enabled + */ + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + @Override + public Optional getCookieDomain() { + return Optional.ofNullable(cookieDomain); + } + + /** + * Sets the domain name of this Cookie. Default value (null). + * + * @param cookieDomain the domain name of this Cookie + */ + public void setCookieDomain(@Nullable String cookieDomain) { + this.cookieDomain = cookieDomain; + } + + @Override + public Optional isCookieSecure() { + return Optional.ofNullable(cookieSecure); + } + + /** + * Sets whether the cookie is secured. Defaults to the secure status of the request. + * + * @param cookieSecure True if the cookie is secure + */ + public void setCookieSecure(Boolean cookieSecure) { + this.cookieSecure = cookieSecure; + } + + @NonNull + @Override + public String getCookieName() { + return this.cookieName; + } + + /** + * Cookie Name. + * + * @param cookieName Cookie name + */ + public void setCookieName(@NonNull String cookieName) { + this.cookieName = cookieName; + } + + @Override + public Optional getCookiePath() { + return Optional.ofNullable(cookiePath); + } + + /** + * Sets the path of the cookie. Default value ({@value #DEFAULT_COOKIEPATH}). + * + * @param cookiePath The path of the cookie. + */ + public void setCookiePath(@Nullable String cookiePath) { + this.cookiePath = cookiePath; + } + + @Override + public Optional isCookieHttpOnly() { + return Optional.ofNullable(cookieHttpOnly); + } + + /** + * Whether the Cookie can only be accessed via HTTP. Default value ({@value #DEFAULT_HTTPONLY}). + * + * @param cookieHttpOnly Whether the Cookie can only be accessed via HTTP + */ + public void setCookieHttpOnly(Boolean cookieHttpOnly) { + this.cookieHttpOnly = cookieHttpOnly; + } + + @Override + public Optional getCookieMaxAge() { + return Optional.ofNullable(cookieMaxAge); + } + + /** + * Sets the maximum age of the cookie. Default value ({@value AccessTokenConfigurationProperties#DEFAULT_EXPIRATION} seconds). + * + * @param cookieMaxAge The maximum age of the cookie + */ + public void setCookieMaxAge(Duration cookieMaxAge) { + this.cookieMaxAge = cookieMaxAge; + } + + @Override + public Optional getCookieSameSite() { + return Optional.of(this.sameSite); + } + + /** + * Cookie Same Site Configuration. It defaults to Strict. + * @param sameSite Same Site Configuration + */ + public void setCookieSameSite(SameSite sameSite) { + this.sameSite = sameSite; + } +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/filter/CsrfFilter.java b/security-csrf/src/main/java/io/micronaut/security/csrf/filter/CsrfFilter.java new file mode 100644 index 0000000000..d864a01b09 --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/filter/CsrfFilter.java @@ -0,0 +1,224 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.csrf.filter; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.order.Ordered; +import io.micronaut.core.util.PathMatcher; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.*; +import io.micronaut.http.annotation.RequestFilter; +import io.micronaut.http.annotation.ServerFilter; +import io.micronaut.http.filter.FilterPatternStyle; +import io.micronaut.http.filter.ServerFilterPhase; +import io.micronaut.http.server.exceptions.ExceptionHandler; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.authentication.AuthorizationException; +import io.micronaut.security.csrf.resolver.CsrfTokenResolver; +import io.micronaut.security.csrf.resolver.FutureCsrfTokenResolver; +import io.micronaut.security.csrf.validator.CsrfTokenValidator; +import io.micronaut.security.filters.SecurityFilter; +import io.micronaut.web.router.RouteMatch; +import io.micronaut.web.router.UriRouteMatch; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +/** + * {@link RequestFilter} which validates CSRF tokens and rejects a request if the token is invalid. + * Which requests are intercepted can be controlled via {@link io.micronaut.security.csrf.CsrfConfiguration}. + * @author Sergio del Amo + * @since 4.11.0 + */ +@Internal +@Requires(property = CsrfFilterConfigurationProperties.PREFIX + ".enabled", value = StringUtils.TRUE, defaultValue = StringUtils.TRUE) +@Requires(classes = { ExceptionHandler.class, HttpRequest.class }) +@Requires(beans = { CsrfTokenValidator.class }) +@ServerFilter(patternStyle = FilterPatternStyle.REGEX, + value = "${" + CsrfFilterConfigurationProperties.PREFIX + ".regex-pattern:" + CsrfFilterConfigurationProperties.DEFAULT_REGEX_PATTERN + "}") +final class CsrfFilter implements Ordered { + private static final Logger LOG = LoggerFactory.getLogger(CsrfFilter.class); + private static final CompletableFuture<@Nullable HttpResponse> PROCEED = CompletableFuture.completedFuture(null); + private final List>> futureCsrfTokenResolvers; + private final List>> csrfTokenResolvers; + private final CsrfTokenValidator> csrfTokenValidator; + private final ExceptionHandler> exceptionHandler; + private final CsrfFilterConfiguration csrfFilterConfiguration; + + CsrfFilter(CsrfFilterConfiguration csrfFilterConfiguration, + List>> futureCsrfTokenResolvers, + List>> csrfTokenResolvers, + CsrfTokenValidator> csrfTokenValidator, + ExceptionHandler> exceptionHandler) { + this.csrfTokenResolvers = csrfTokenResolvers; + this.futureCsrfTokenResolvers = futureCsrfTokenResolvers.isEmpty() + ? futureCsrfTokenResolvers + : FutureCsrfTokenResolver.of(csrfTokenResolvers, futureCsrfTokenResolvers); + this.csrfTokenValidator = csrfTokenValidator; + this.exceptionHandler = exceptionHandler; + this.csrfFilterConfiguration = csrfFilterConfiguration; + } + + @RequestFilter + @Nullable + + public CompletableFuture<@Nullable HttpResponse> csrfFilter(@NonNull HttpRequest request) { + if (!shouldTheFilterProcessTheRequestAccordingToTheUriMatch(request)) { + return PROCEED; + } + if (!shouldTheFilterProcessTheRequestAccordingToTheHttpMethod(request)) { + return PROCEED; + } + if (!shouldTheFilterProcessTheRequestAccordingToTheContentType(request)) { + return PROCEED; + } + return futureCsrfTokenResolvers.isEmpty() + ? imperativeFilter(request) + : reactiveFilter(request); + } + + boolean shouldTheFilterProcessTheRequestAccordingToTheUriMatch(HttpRequest request) { + RouteMatch routeMatch = request.getAttribute(HttpAttributes.ROUTE_MATCH, RouteMatch.class).orElse(null); + if (routeMatch instanceof UriRouteMatch uriRouteMatch) { + return shouldTheFilterProcessTheRequestAccordingToTheUriMatch(uriRouteMatch); + } + return true; + } + + boolean shouldTheFilterProcessTheRequestAccordingToTheUriMatch(UriRouteMatch uriRouteMatch) { + return shouldTheFilterProcessTheRequestAccordingToTheUriMatch(uriRouteMatch.getUri()); + } + + boolean shouldTheFilterProcessTheRequestAccordingToTheUriMatch(String uri) { + boolean matches = PathMatcher.REGEX.matches(csrfFilterConfiguration.getRegexPattern(), uri); + if (!matches) { + if (LOG.isTraceEnabled()) { + LOG.trace("Request uri {} does not match fitler regex pattern {}", uri, csrfFilterConfiguration.getRegexPattern()); + } + return false; + } + return true; + } + + private CompletableFuture<@Nullable HttpResponse> reactiveFilter(HttpRequest request) { + List> futures = futureCsrfTokenResolvers.stream() + .map(resolver -> resolver.resolveToken(request) + .thenApply(csrfToken -> { + if (LOG.isTraceEnabled()) { + LOG.trace("CSRF Token resolved"); + } + return csrfTokenValidator.validateCsrfToken(request, csrfToken); + }) + ) + .toList(); + CompletableFuture[] futuresArray = futures.toArray(new CompletableFuture[0]); + return CompletableFuture.allOf(futuresArray) + .thenApply(v -> futures.stream().map(CompletableFuture::join).toList()) + .thenApply(validations -> { + if (validations.stream().anyMatch(Boolean::booleanValue)) { + return null; + } else if (LOG.isTraceEnabled()) { + LOG.trace("CSRF Token validation failed"); + } + return unauthorized(request); + }); + } + + private CompletableFuture<@Nullable HttpResponse> imperativeFilter(HttpRequest request) { + String csrfToken = resolveCsrfToken(request); + if (StringUtils.isEmpty(csrfToken)) { + if (LOG.isTraceEnabled()) { + LOG.trace("Request rejected by the {} because no CSRF Token found", this.getClass().getSimpleName()); + } + return reactiveUnauthorized(request); + } + if (csrfTokenValidator.validateCsrfToken(request, csrfToken)) { + return PROCEED; + } + if (LOG.isDebugEnabled()) { + LOG.debug("Request rejected by the CSRF Filter because the CSRF Token validation failed"); + } + return reactiveUnauthorized(request); + } + + private boolean shouldTheFilterProcessTheRequestAccordingToTheContentType(@NonNull HttpRequest request) { + final MediaType contentType = request.getContentType().orElse(null); + if (contentType != null && csrfFilterConfiguration.getContentTypes().stream().noneMatch(method -> method.equals(contentType))) { + if (LOG.isTraceEnabled()) { + LOG.trace("Request {} {} with content type {} is not processed by the CSRF filter. CSRF filter only processes Content Types: {}", + request.getMethod(), + request.getPath(), + contentType, + csrfFilterConfiguration.getContentTypes().stream().map(MediaType::toString).toList()); + } + return false; + } + return true; + } + + private boolean shouldTheFilterProcessTheRequestAccordingToTheHttpMethod(@NonNull HttpRequest request) { + if (csrfFilterConfiguration.getMethods().stream().noneMatch(method -> method.equals(request.getMethod()))) { + if (LOG.isTraceEnabled()) { + LOG.trace("Request {} {} not processed by the CSRF filter. CSRF filter only processes HTTP Methods: {}", + request.getMethod(), + request.getPath(), + csrfFilterConfiguration.getMethods().stream().map(HttpMethod::name).toList()); + } + return false; + } + return true; + } + + @Nullable + private String resolveCsrfToken(@NonNull HttpRequest request) { + for (CsrfTokenResolver> tokenResolver : csrfTokenResolvers) { + Optional tokenOptional = tokenResolver.resolveToken(request); + if (tokenOptional.isPresent()) { + if (LOG.isTraceEnabled()) { + LOG.trace("CSRF token resolved via {}", tokenResolver.getClass().getSimpleName()); + } + return tokenOptional.get(); + } + } + if (LOG.isTraceEnabled()) { + LOG.trace("No CSRF token found in request"); + } + return null; + } + + @NonNull + private CompletableFuture<@Nullable HttpResponse> reactiveUnauthorized(@NonNull HttpRequest request) { + return CompletableFuture.completedFuture(unauthorized(request)); + } + + @NonNull + private MutableHttpResponse unauthorized(@NonNull HttpRequest request) { + Authentication authentication = request.getAttribute(SecurityFilter.AUTHENTICATION, Authentication.class) + .orElse(null); + return exceptionHandler.handle(request, + new AuthorizationException(authentication)); + } + + @Override + public int getOrder() { + return ServerFilterPhase.SECURITY.order() + 100; // after {@link SecurityFilter} + } +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/filter/CsrfFilterConfiguration.java b/security-csrf/src/main/java/io/micronaut/security/csrf/filter/CsrfFilterConfiguration.java new file mode 100644 index 0000000000..3fd3c06415 --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/filter/CsrfFilterConfiguration.java @@ -0,0 +1,50 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.csrf.filter; + +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.util.Toggleable; +import io.micronaut.http.HttpMethod; +import io.micronaut.http.MediaType; +import java.util.Set; + +/** + * @author Sergio del Amo + * @since 4.11.0 + */ +public interface CsrfFilterConfiguration extends Toggleable { + + /** + * + * @return Regular expression pattern. Filter will only process requests whose path matches this pattern. + */ + @NonNull + String getRegexPattern(); + + /** + * + * @return HTTP methods. Filter will only process requests whose method matches any of these methods. + */ + @NonNull + Set getMethods(); + + /** + * + * @return HTTP methods. Filter will only process requests whose content type matches any of these content types. + */ + @NonNull + Set getContentTypes(); +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/filter/CsrfFilterConfigurationProperties.java b/security-csrf/src/main/java/io/micronaut/security/csrf/filter/CsrfFilterConfigurationProperties.java new file mode 100644 index 0000000000..e06b05e3a5 --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/filter/CsrfFilterConfigurationProperties.java @@ -0,0 +1,115 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.csrf.filter; + +import io.micronaut.context.annotation.ConfigurationProperties; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.http.HttpMethod; +import io.micronaut.http.MediaType; +import io.micronaut.security.csrf.CsrfConfiguration; + +import java.util.Set; + +@Requires(classes = { HttpMethod.class, MediaType.class }) +@Internal +@ConfigurationProperties(CsrfFilterConfigurationProperties.PREFIX) +final class CsrfFilterConfigurationProperties implements CsrfFilterConfiguration { + public static final String PREFIX = CsrfConfiguration.PREFIX + ".filter"; + + /** + * The default enable value. + */ + @SuppressWarnings("WeakerAccess") + public static final boolean DEFAULT_ENABLED = true; + + /** + * The default regex pattern. + */ + @SuppressWarnings("WeakerAccess") + public static final String DEFAULT_REGEX_PATTERN = "^.*$"; + + private static final Set DEFAULT_METHODS = Set.of( + HttpMethod.POST, + HttpMethod.PUT, + HttpMethod.DELETE, + HttpMethod.PATCH + ); + + private static final Set DEFAULT_CONTENT_TYPES = Set.of( + MediaType.APPLICATION_FORM_URLENCODED_TYPE, + MediaType.MULTIPART_FORM_DATA_TYPE + ); + private boolean enabled = DEFAULT_ENABLED; + private String regexPattern = DEFAULT_REGEX_PATTERN; + private Set methods = DEFAULT_METHODS; + private Set contentTypes = DEFAULT_CONTENT_TYPES; + + @Override + @NonNull + public Set getMethods() { + return methods; + } + + /** + * Filter will only process requests whose method matches any of these methods. Default Value is POST, PUT, DELETE, PATCH. + * @param methods HTTP methods. + */ + public void setMethods(@NonNull Set methods) { + this.methods = methods; + } + + @Override + @NonNull + public Set getContentTypes() { + return contentTypes; + } + + /** + * Filter will only process requests whose content type matches any of these content types. Default Value is application/x-www-form-urlencoded, multipart/form-data. + * @param contentTypes Content Types + */ + public void setContentTypes(@NonNull Set contentTypes) { + this.contentTypes = contentTypes; + } + + @Override + public boolean isEnabled() { + return enabled; + } + + /** + * Whether the filter is enabled. Default value {@value #DEFAULT_ENABLED}. + * @param enabled Whether the filter is enabled. + */ + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + @Override + public String getRegexPattern() { + return regexPattern; + } + + /** + * CSRF filter processes only request paths matching this regular expression. Default Value: {@value #DEFAULT_REGEX_PATTERN} + * @param regexPattern Regular expression pattern for the filter. + */ + public void setRegexPattern(String regexPattern) { + this.regexPattern = regexPattern; + } +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/filter/package-info.java b/security-csrf/src/main/java/io/micronaut/security/csrf/filter/package-info.java new file mode 100644 index 0000000000..d414412654 --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/filter/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Classes related to the CSRF Filter. + * @author Sergio del Amo + * @since 4.11.0 + */ +package io.micronaut.security.csrf.filter; diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/generator/CsrfHmacTokenGenerator.java b/security-csrf/src/main/java/io/micronaut/security/csrf/generator/CsrfHmacTokenGenerator.java new file mode 100644 index 0000000000..346fea95d4 --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/generator/CsrfHmacTokenGenerator.java @@ -0,0 +1,42 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.csrf.generator; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; + +/** + * CSRF token Generation with HMAC. + * @author Sergio del Amo + * @since 4.11.0 + * @param request + */ +@Internal +public interface CsrfHmacTokenGenerator extends CsrfTokenGenerator { + /** + * Dot is used as separator between the HMAC and the random value. As the random value and hmac are base64 encoded, they will not contain a dot. + */ + String HMAC_RANDOM_SEPARATOR = "."; + + /** + * Generates an HMAC. + * @param request Request + * @param base64EncodedRandomValue Cryptographic random value encoded as Base64 + * @return HMAC hash + */ + @NonNull + String hmac(@NonNull T request, @NonNull String base64EncodedRandomValue); +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/generator/CsrfTokenGenerator.java b/security-csrf/src/main/java/io/micronaut/security/csrf/generator/CsrfTokenGenerator.java new file mode 100644 index 0000000000..12f2b1e2f4 --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/generator/CsrfTokenGenerator.java @@ -0,0 +1,37 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.csrf.generator; + +import io.micronaut.context.annotation.DefaultImplementation; +import io.micronaut.core.annotation.NonNull; + +/** + * CSRF token Generation. + * @author Sergio del Amo + * @since 4.11.0 + * @param Request + */ +@DefaultImplementation(DefaultCsrfTokenGenerator.class) +@FunctionalInterface +public interface CsrfTokenGenerator { + /** + * Generates a CSRF Token. + * @param request Request + * @return A CSRF Token. + */ + @NonNull + String generateCsrfToken(@NonNull T request); +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/generator/DefaultCsrfTokenGenerator.java b/security-csrf/src/main/java/io/micronaut/security/csrf/generator/DefaultCsrfTokenGenerator.java new file mode 100644 index 0000000000..a1ba260346 --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/generator/DefaultCsrfTokenGenerator.java @@ -0,0 +1,111 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.csrf.generator; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.context.exceptions.ConfigurationException; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.cookie.CookieConfiguration; +import io.micronaut.security.csrf.CsrfConfiguration; +import io.micronaut.security.session.SessionIdResolver; +import io.micronaut.security.utils.HMacUtils; +import jakarta.inject.Singleton; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * Default implementation of {@link CsrfTokenGenerator} which generates a CSRF Token prefixed by an HMAC if a secret key is set. + * @see Pseudo Code for implementing hmac CSRF tokens + * @author Sergio del Amo + * @since 4.11.0 + * @param Request + */ +@Requires(classes = CookieConfiguration.class) +@Singleton +final class DefaultCsrfTokenGenerator implements CsrfHmacTokenGenerator { + /** + * hmac random value separator. + */ + private static final String SESSION_RANDOM_SEPARATOR = "!"; + private final SecureRandom secureRandom = new SecureRandom(); + private final CsrfConfiguration csrfConfiguration; + private final SessionIdResolver sessionIdResolver; + + /** + * + * @param csrfConfiguration CSRF Configuration + * @param sessionIdResolver SessionID Resolver + */ + DefaultCsrfTokenGenerator(CsrfConfiguration csrfConfiguration, + SessionIdResolver sessionIdResolver) { + this.csrfConfiguration = csrfConfiguration; + this.sessionIdResolver = sessionIdResolver; + } + + @Override + @NonNull + public String generateCsrfToken(@NonNull T request) { + byte[] tokenBytes = new byte[csrfConfiguration.getRandomValueSize()]; + secureRandom.nextBytes(tokenBytes); + String randomValue = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); // Cryptographic random value + + String hmac = hmac(request, randomValue); + // Add the `randomValue` to the HMAC hash to create the final CSRF token. Avoid using the `message` because it contains the sessionID in plain text, which the server already stores separately. + return hmac + HMAC_RANDOM_SEPARATOR + randomValue; + } + + /** + * + * @param request Request + * @param base64EncodedRandomValue Cryptographic random value encoded as Base64 + * @return HMAC hash + */ + @Override + @NonNull + public String hmac(@NonNull T request, @NonNull String base64EncodedRandomValue) { + // Gather the values + String secret = csrfConfiguration.getSecretKey(); + String sessionID = sessionIdResolver.findSessionId(request).orElse(""); // Current authenticated user session + + // Create the CSRF Token + String message = hmacMessagePayload(sessionID, base64EncodedRandomValue); + try { + return StringUtils.isNotEmpty(secret) + ? HMacUtils.base64EncodedHmacSha256(message, secret) // Generate the HMAC hash + : ""; + } catch (InvalidKeyException ex) { + throw new ConfigurationException("Invalid secret key for signing the CSRF token"); + } catch (NoSuchAlgorithmException ex) { + throw new ConfigurationException("Invalid algorithm for signing the CSRF token"); + } + } + + static String hmacMessagePayload(String sessionId, String randomValue) { + // both session id and randomValue will be base64 encoded strings to ensure they don't contain the separator ! as a substring. + final String base64SessionId = Base64.getEncoder().encodeToString(sessionId.getBytes()); + return base64SessionId.length() + + SESSION_RANDOM_SEPARATOR + + base64SessionId + + SESSION_RANDOM_SEPARATOR + + randomValue.length() + + SESSION_RANDOM_SEPARATOR + + randomValue; + } +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/generator/package-info.java b/security-csrf/src/main/java/io/micronaut/security/csrf/generator/package-info.java new file mode 100644 index 0000000000..7079368d66 --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/generator/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Classes related to CSRF Token Generation. + * @author Sergio del Amo + * @since 4.11.0 + */ +package io.micronaut.security.csrf.generator; diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/package-info.java b/security-csrf/src/main/java/io/micronaut/security/csrf/package-info.java new file mode 100644 index 0000000000..33516fbc13 --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/package-info.java @@ -0,0 +1,29 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Classes related to Cross Site Request Forgery (CSRF). + * @see Cross Site Request Forgery (CSRF) + * @see CSRF Prevention Cheat Sheet + * @author Sergio del Amo + * @since 4.11.0 + */ +@Requires(property = CsrfConfiguration.PREFIX + ".enabled", value = StringUtils.TRUE, defaultValue = StringUtils.TRUE) +@Configuration +package io.micronaut.security.csrf; + +import io.micronaut.context.annotation.Configuration; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.util.StringUtils; diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/repository/CompositeCsrfTokenRepository.java b/security-csrf/src/main/java/io/micronaut/security/csrf/repository/CompositeCsrfTokenRepository.java new file mode 100644 index 0000000000..35761a2749 --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/repository/CompositeCsrfTokenRepository.java @@ -0,0 +1,52 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.csrf.repository; + +import io.micronaut.context.annotation.Primary; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import jakarta.inject.Singleton; + +import java.util.List; +import java.util.Optional; + +/** + * Composite Pattern implementation of {@link CsrfTokenRepository}. + * @see Composite Pattern + * @param Request + */ +@Internal +@Primary +@Singleton +final class CompositeCsrfTokenRepository implements CsrfTokenRepository { + private final List> repositories; + + /** + * + * @param repositories CSRF Token Repositories + */ + public CompositeCsrfTokenRepository(List> repositories) { + this.repositories = repositories; + } + + @Override + @NonNull + public Optional findCsrfToken(@NonNull T request) { + return repositories.stream() + .flatMap(r -> r.findCsrfToken(request).stream()) + .findFirst(); + } +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/repository/CookieCsrfTokenRepository.java b/security-csrf/src/main/java/io/micronaut/security/csrf/repository/CookieCsrfTokenRepository.java new file mode 100644 index 0000000000..4d38066ac1 --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/repository/CookieCsrfTokenRepository.java @@ -0,0 +1,54 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.csrf.repository; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.cookie.Cookie; +import io.micronaut.security.csrf.CsrfConfiguration; +import jakarta.inject.Singleton; + +import java.util.Optional; + +/** + * Retrieves a CSRF Token from a Cookie named {@link CsrfConfiguration#getCookieName()}, for example, in a Double Submit Cookie pattern. + * @author Sergio del Amo + * @since 4.11.0 + */ +@Requires(classes = HttpRequest.class) +@Requires(property = CsrfConfiguration.PREFIX + ".repository.cookie.enabled", value = StringUtils.TRUE, defaultValue = StringUtils.TRUE) +@Singleton +public class CookieCsrfTokenRepository implements CsrfTokenRepository> { + private final CsrfConfiguration csrfConfiguration; + + /** + * + * @param csrfConfiguration CSRF Configuration + */ + public CookieCsrfTokenRepository(CsrfConfiguration csrfConfiguration) { + this.csrfConfiguration = csrfConfiguration; + } + + @Override + @NonNull + public Optional findCsrfToken(@NonNull HttpRequest request) { + return request.getCookies() + .findCookie(csrfConfiguration.getCookieName()) + .map(Cookie::getValue); + } +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/repository/CsrfLoginCookieProvider.java b/security-csrf/src/main/java/io/micronaut/security/csrf/repository/CsrfLoginCookieProvider.java new file mode 100644 index 0000000000..a4116f7451 --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/repository/CsrfLoginCookieProvider.java @@ -0,0 +1,57 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.csrf.repository; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.cookie.Cookie; +import io.micronaut.security.csrf.CsrfConfiguration; +import io.micronaut.security.csrf.generator.CsrfTokenGenerator; +import io.micronaut.security.token.cookie.LoginCookieProvider; +import jakarta.inject.Singleton; + +/** + * Provides a CSRF Cookie which can be included in the login response. + * @author Sergio del Amo + * @since 4.11.0 + */ +@Requires(classes = HttpRequest.class) +@Singleton +public class CsrfLoginCookieProvider implements LoginCookieProvider> { + private final CsrfTokenGenerator> csrfTokenGenerator; + private final CsrfConfiguration csrfConfiguration; + + /** + * + * @param csrfTokenGenerator CSRF Token Generator + * @param csrfConfiguration CSRF Configuration + */ + public CsrfLoginCookieProvider(CsrfTokenGenerator> csrfTokenGenerator, + CsrfConfiguration csrfConfiguration) { + this.csrfTokenGenerator = csrfTokenGenerator; + this.csrfConfiguration = csrfConfiguration; + } + + @Override + @NonNull + public Cookie provideCookie(@NonNull HttpRequest request) { + String csrfToken = csrfTokenGenerator.generateCsrfToken(request); + Cookie cookie = Cookie.of(csrfConfiguration.getCookieName(), csrfToken); + cookie.configure(csrfConfiguration, request.isSecure()); + return cookie; + } +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/repository/CsrfTokenRepository.java b/security-csrf/src/main/java/io/micronaut/security/csrf/repository/CsrfTokenRepository.java new file mode 100644 index 0000000000..01faa96bed --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/repository/CsrfTokenRepository.java @@ -0,0 +1,38 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.csrf.repository; + +import io.micronaut.core.annotation.Indexed; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.order.Ordered; + +import java.util.Optional; + +/** + * Repository API for CSRF Tokens. + * @param Request + */ +@Indexed(CsrfTokenRepository.class) +@FunctionalInterface +public interface CsrfTokenRepository extends Ordered { + /** + * + * @param request Request + * @return A CSRF token or an empty optional. + */ + @NonNull + Optional findCsrfToken(@NonNull T request); +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/resolver/CsrfTokenResolver.java b/security-csrf/src/main/java/io/micronaut/security/csrf/resolver/CsrfTokenResolver.java new file mode 100644 index 0000000000..b4c69f10cb --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/resolver/CsrfTokenResolver.java @@ -0,0 +1,40 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.csrf.resolver; + +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.order.Ordered; + +import java.util.Optional; + +/** + * Attempts to resolve a CSRF token from the provided request. + * {@link CsrfTokenResolver} is an {@link Ordered} api. Override the {@link #getOrder()} method to provide a custom order. + * + * @author Sergio del Amo + * @since 1.1.0 + * @param request + */ +public interface CsrfTokenResolver extends Ordered { + + /** + * + * @param request The Request. Maybe an HTTP Request. + * @return A CSRF token or an empty Optional if the token cannot be resolved. + */ + @NonNull + Optional resolveToken(@NonNull T request); +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/resolver/FieldCsrfTokenResolver.java b/security-csrf/src/main/java/io/micronaut/security/csrf/resolver/FieldCsrfTokenResolver.java new file mode 100644 index 0000000000..bac3e0977d --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/resolver/FieldCsrfTokenResolver.java @@ -0,0 +1,68 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.csrf.resolver; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.ServerHttpRequest; +import io.micronaut.http.server.filter.FilterBodyParser; +import io.micronaut.security.csrf.CsrfConfiguration; +import jakarta.inject.Singleton; +import java.util.concurrent.CompletableFuture; + +/** + * Resolves a CSRF token from a form-urlencoded body using the {@link ServerHttpRequest#byteBody()} API. + * + * @since 2.0.0 + */ +@Requires(classes = HttpRequest.class) +@Requires(property = CsrfConfiguration.PREFIX + ".token-resolvers.field.enabled", value = StringUtils.TRUE, defaultValue = StringUtils.TRUE) +@Singleton +final class FieldCsrfTokenResolver implements FutureCsrfTokenResolver> { + private final CsrfConfiguration csrfConfiguration; + private final FilterBodyParser filterBodyParser; + + /** + * + * @param csrfConfiguration CSRF Configuration + * @param filterBodyParser Filter Body Parser + */ + FieldCsrfTokenResolver(CsrfConfiguration csrfConfiguration, FilterBodyParser filterBodyParser) { + this.csrfConfiguration = csrfConfiguration; + this.filterBodyParser = filterBodyParser; + } + + @Override + @NonNull + public CompletableFuture resolveToken(@NonNull HttpRequest request) { + if (request instanceof ServerHttpRequest serverHttpRequest) { + return resolveToken(serverHttpRequest); + } + return CompletableFuture.completedFuture(null); + } + + private CompletableFuture resolveToken(ServerHttpRequest request) { + return filterBodyParser.parseBody(request) + .thenApply(m -> { + Object csrfToken = m.get(csrfConfiguration.getFieldName()); + return csrfToken == null || StringUtils.isEmpty(csrfToken.toString()) + ? null + : csrfToken.toString(); + }); + } +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/resolver/FutureCsrfTokenResolver.java b/security-csrf/src/main/java/io/micronaut/security/csrf/resolver/FutureCsrfTokenResolver.java new file mode 100644 index 0000000000..d6613842eb --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/resolver/FutureCsrfTokenResolver.java @@ -0,0 +1,61 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.csrf.resolver; + +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.order.OrderUtil; +import io.micronaut.core.order.Ordered; +import io.micronaut.core.util.CollectionUtils; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Attempts to resolve a CSRF token from the provided request. + * {@link FutureCsrfTokenResolver} is an {@link Ordered} api. Override the {@link #getOrder()} method to provide a custom order. + * + * @author Sergio del Amo + * @since 1.1.0 + * @param request + */ +public interface FutureCsrfTokenResolver extends Ordered { + + /** + * + * @param request The Request. Maybe an HTTP Request. + * @return A CSRF token or an empty Optional if the token cannot be resolved. + */ + @NonNull + CompletableFuture resolveToken(@NonNull T request); + + /** + * + * @param resolvers Imperative CSRF Token Resolvers + * @param futureCsrfTokenResolvers Reactive CSRF Token Resolvers + * @return Returns a List of {@link FutureCsrfTokenResolver} instances containing every reactive resolver plus the imperative resolvers adapted to imperative. + * @param request type + */ + @NonNull + static List> of( + @NonNull List> resolvers, + @NonNull List> futureCsrfTokenResolvers) { + List> result = CollectionUtils.concat(futureCsrfTokenResolvers, + resolvers.stream() + .map(resolver -> (FutureCsrfTokenResolver) new FutureCsrfTokenResolverAdapter<>(resolver)) + .toList()); + OrderUtil.sort(result); + return result; + } +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/resolver/FutureCsrfTokenResolverAdapter.java b/security-csrf/src/main/java/io/micronaut/security/csrf/resolver/FutureCsrfTokenResolverAdapter.java new file mode 100644 index 0000000000..0ed16f8d14 --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/resolver/FutureCsrfTokenResolverAdapter.java @@ -0,0 +1,48 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.csrf.resolver; + +import io.micronaut.core.annotation.NonNull; + +import java.util.concurrent.CompletableFuture; + +/** + * Adapter from {@link CsrfTokenResolver} to {@link FutureCsrfTokenResolver}. + * @param Request + */ +final class FutureCsrfTokenResolverAdapter implements FutureCsrfTokenResolver { + + private final CsrfTokenResolver csrfTokenResolver; + + /** + * + * @param csrfTokenResolver CSRF Token resolver + */ + public FutureCsrfTokenResolverAdapter(CsrfTokenResolver csrfTokenResolver) { + this.csrfTokenResolver = csrfTokenResolver; + } + + @Override + @NonNull + public CompletableFuture resolveToken(@NonNull T request) { + return CompletableFuture.completedFuture(csrfTokenResolver.resolveToken(request).orElse(null)); + } + + @Override + public int getOrder() { + return csrfTokenResolver.getOrder(); + } +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/resolver/HttpHeaderCsrfTokenResolver.java b/security-csrf/src/main/java/io/micronaut/security/csrf/resolver/HttpHeaderCsrfTokenResolver.java new file mode 100644 index 0000000000..3f665b62d0 --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/resolver/HttpHeaderCsrfTokenResolver.java @@ -0,0 +1,67 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.csrf.resolver; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.HttpHeaders; +import io.micronaut.http.HttpRequest; +import io.micronaut.security.csrf.CsrfConfiguration; +import jakarta.inject.Singleton; + +import java.util.Optional; + +/** + * Resolves a CSRF token from a request HTTP Header named {@link CsrfConfiguration#getHeaderName()}. + * @author Sergio del Amo + * @since 4.11.0 + */ +@Requires(classes = HttpRequest.class) +@Requires(property = CsrfConfiguration.PREFIX + ".token-resolvers.http-header.enabled", value = StringUtils.TRUE, defaultValue = StringUtils.TRUE) +@Singleton +@Internal +final class HttpHeaderCsrfTokenResolver implements CsrfTokenResolver> { + private static final int ORDER = -100; + private final String lowerHeaderName; + private final String headerName; + + HttpHeaderCsrfTokenResolver(CsrfConfiguration csrfConfiguration) { + headerName = csrfConfiguration.getHeaderName(); + lowerHeaderName = headerName.toLowerCase(); + } + + @Override + @NonNull + public Optional resolveToken(@NonNull HttpRequest request) { + final HttpHeaders httpHeaders = request.getHeaders(); + String csrfToken = httpHeaders.get(headerName); + if (StringUtils.isNotEmpty(csrfToken)) { + return Optional.of(csrfToken); + } + csrfToken = httpHeaders.get(lowerHeaderName); + if (StringUtils.isNotEmpty(csrfToken)) { + return Optional.of(csrfToken); + } + return Optional.empty(); + } + + @Override + public int getOrder() { + return ORDER; + } +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/resolver/package-info.java b/security-csrf/src/main/java/io/micronaut/security/csrf/resolver/package-info.java new file mode 100644 index 0000000000..f912fa67c9 --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/resolver/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Classes related to CSRF Token Resolution. + * @author Sergio del Amo + * @since 4.11.0 + */ +package io.micronaut.security.csrf.resolver; diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/session/CsrfSessionPopulator.java b/security-csrf/src/main/java/io/micronaut/security/csrf/session/CsrfSessionPopulator.java new file mode 100644 index 0000000000..c973a0b31c --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/session/CsrfSessionPopulator.java @@ -0,0 +1,55 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.csrf.session; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.csrf.CsrfConfiguration; +import io.micronaut.security.csrf.generator.CsrfTokenGenerator; +import io.micronaut.security.session.SessionPopulator; +import io.micronaut.session.Session; +import jakarta.inject.Singleton; + +/** + * Populates the session with a CSRF token. + * @author Sergio del Amo + * @since 4.11.0 + * @param Request + */ +@Singleton +@Internal +final class CsrfSessionPopulator implements SessionPopulator { + private final CsrfConfiguration csrfConfiguration; + private final CsrfTokenGenerator csrfTokenGenerator; + + /** + * + * @param csrfConfiguration CSRF Configuration + * @param csrfTokenGenerator CSRF Token Generator + */ + public CsrfSessionPopulator(CsrfConfiguration csrfConfiguration, + CsrfTokenGenerator csrfTokenGenerator) { + this.csrfConfiguration = csrfConfiguration; + this.csrfTokenGenerator = csrfTokenGenerator; + } + + @Override + public void populateSession(T request, + Authentication authentication, + Session session) { + session.put(csrfConfiguration.getHttpSessionName(), csrfTokenGenerator.generateCsrfToken(request)); + } +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/session/SessionCsrfTokenRepository.java b/security-csrf/src/main/java/io/micronaut/security/csrf/session/SessionCsrfTokenRepository.java new file mode 100644 index 0000000000..047d910726 --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/session/SessionCsrfTokenRepository.java @@ -0,0 +1,55 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.csrf.session; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.HttpRequest; +import io.micronaut.security.csrf.CsrfConfiguration; +import io.micronaut.security.csrf.repository.CsrfTokenRepository; +import io.micronaut.session.http.SessionForRequest; +import jakarta.inject.Singleton; + +import java.util.Optional; + +/** + * Implementation of {@link CsrfTokenRepository} that retrieves the CSRF token from an HTTP session using the key defined in {@link CsrfConfiguration#getHttpSessionName()}. + * @author Sergio del Amo + * @since 4.11.0 + */ +@Requires(classes = HttpRequest.class) +@Requires(beans = CsrfConfiguration.class) +@Requires(property = CsrfConfiguration.PREFIX + ".repositories.session.enabled", value = StringUtils.TRUE, defaultValue = StringUtils.TRUE) +@Singleton +public class SessionCsrfTokenRepository implements CsrfTokenRepository> { + private final CsrfConfiguration csrfConfiguration; + + /** + * + * @param csrfConfiguration CSRF Configuration + */ + public SessionCsrfTokenRepository(CsrfConfiguration csrfConfiguration) { + this.csrfConfiguration = csrfConfiguration; + } + + @Override + @NonNull + public Optional findCsrfToken(@NonNull HttpRequest request) { + return SessionForRequest.find(request) + .flatMap(session -> session.get(csrfConfiguration.getHttpSessionName(), String.class)); + } +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/session/package-info.java b/security-csrf/src/main/java/io/micronaut/security/csrf/session/package-info.java new file mode 100644 index 0000000000..771a152c5b --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/session/package-info.java @@ -0,0 +1,27 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Classes related to CSRF and HTTP session. + * @author Sergio del Amo + * @since 4.11.0 + */ +@Requires(classes = Session.class) +@Configuration +package io.micronaut.security.csrf.session; + +import io.micronaut.context.annotation.Configuration; +import io.micronaut.context.annotation.Requires; +import io.micronaut.session.Session; diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/validator/CsrfTokenValidator.java b/security-csrf/src/main/java/io/micronaut/security/csrf/validator/CsrfTokenValidator.java new file mode 100644 index 0000000000..8d7bb57bfb --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/validator/CsrfTokenValidator.java @@ -0,0 +1,36 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.csrf.validator; + +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.order.Ordered; + +/** + * CSRF Token Validation. + * @author Sergio del Amo + * @since 4.11.0 + * @param request + */ +@FunctionalInterface +public interface CsrfTokenValidator extends Ordered { + /** + * Given a CSRF Token, validates whether it is valid. + * @param request Request + * @param token CSRF Token + * @return Whether the CSRF token is valid + */ + boolean validateCsrfToken(@NonNull T request, @NonNull String token); +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/validator/RepositoryCsrfTokenValidator.java b/security-csrf/src/main/java/io/micronaut/security/csrf/validator/RepositoryCsrfTokenValidator.java new file mode 100644 index 0000000000..2da356045a --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/validator/RepositoryCsrfTokenValidator.java @@ -0,0 +1,84 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.csrf.validator; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Internal; +import io.micronaut.security.csrf.generator.CsrfHmacTokenGenerator; +import io.micronaut.security.csrf.repository.CsrfTokenRepository; +import jakarta.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; +import java.security.MessageDigest; + +/** + * {@link CsrfTokenValidator} implementation that uses a {@link CsrfTokenRepository}. + * First attempts to retrieve a token from a {@link CsrfTokenRepository} and if found validates it against the supplied token. + * @param Request + * @since 4.11.0 + * @author Sergio del Amo + */ +@Requires(beans = { CsrfTokenRepository.class, CsrfHmacTokenGenerator.class}) +@Internal +@Singleton +class RepositoryCsrfTokenValidator implements CsrfTokenValidator { + private static final Logger LOG = LoggerFactory.getLogger(RepositoryCsrfTokenValidator.class); + private final List> repositories; + private final CsrfHmacTokenGenerator defaultCsrfTokenGenerator; + + /** + * + * @param repositories CSRF Token Repositories + * @param defaultCsrfTokenGenerator Default CSRF Token Generator + */ + public RepositoryCsrfTokenValidator(List> repositories, + CsrfHmacTokenGenerator defaultCsrfTokenGenerator) { + this.repositories = repositories; + this.defaultCsrfTokenGenerator = defaultCsrfTokenGenerator; + } + + @Override + public boolean validateCsrfToken(T request, String csrfTokenInRequest) { + for (CsrfTokenRepository repo : repositories) { + Optional csrfTokenOptional = repo.findCsrfToken(request); + if (csrfTokenOptional.isPresent()) { + String csrfTokenInRepository = csrfTokenOptional.get(); + if (csrfTokenInRepository.equals(csrfTokenInRequest) && validateHmac(request, csrfTokenInRequest)) { + return true; + } + } + } + return false; + } + + private boolean validateHmac(T request, String csrfTokenInRequest) { + String[] arr = csrfTokenInRequest.split("\\" + CsrfHmacTokenGenerator.HMAC_RANDOM_SEPARATOR); + if (arr.length != 2) { + if (LOG.isWarnEnabled()) { + LOG.warn("Invalid CSRF token: {}", csrfTokenInRequest); + } + return false; + } + String hmac = arr[0]; + String randomValue = arr[1]; + String expectedHmac = defaultCsrfTokenGenerator.hmac(request, randomValue); + return MessageDigest.isEqual(expectedHmac.getBytes(StandardCharsets.UTF_8), hmac.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/security-csrf/src/main/java/io/micronaut/security/csrf/validator/package-info.java b/security-csrf/src/main/java/io/micronaut/security/csrf/validator/package-info.java new file mode 100644 index 0000000000..1b3968512c --- /dev/null +++ b/security-csrf/src/main/java/io/micronaut/security/csrf/validator/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Classes related to CSRF token validation. + * @author Sergio del Amo + * @since 4.11.0 + */ +package io.micronaut.security.csrf.validator; diff --git a/security-csrf/src/test/java/io/micronaut/security/CsrfConfigurationDisabledTest.java b/security-csrf/src/test/java/io/micronaut/security/CsrfConfigurationDisabledTest.java new file mode 100644 index 0000000000..bc264f8cad --- /dev/null +++ b/security-csrf/src/test/java/io/micronaut/security/CsrfConfigurationDisabledTest.java @@ -0,0 +1,23 @@ +package io.micronaut.security; + +import io.micronaut.context.BeanContext; +import io.micronaut.context.annotation.Property; +import io.micronaut.core.util.StringUtils; +import io.micronaut.security.csrf.CsrfConfiguration; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +@Property(name = "micronaut.security.csrf.enabled", value = StringUtils.FALSE) +@MicronautTest(startApplication = false) +class CsrfConfigurationDisabledTest { + + @Inject + BeanContext beanContext; + + @Test + void disabledCsrf() { + assertFalse(beanContext.containsBean(CsrfConfiguration.class)); + } +} diff --git a/security-csrf/src/test/java/io/micronaut/security/csrf/CsrfConfigurationEnabledSetTest.java b/security-csrf/src/test/java/io/micronaut/security/csrf/CsrfConfigurationEnabledSetTest.java new file mode 100644 index 0000000000..388a72b542 --- /dev/null +++ b/security-csrf/src/test/java/io/micronaut/security/csrf/CsrfConfigurationEnabledSetTest.java @@ -0,0 +1,15 @@ +package io.micronaut.security.csrf; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +class CsrfConfigurationEnabledSetTest { + + @Test + void csrfSetEnabled() { + CsrfConfigurationProperties configuration = new CsrfConfigurationProperties(); + configuration.setEnabled(false); + assertFalse(configuration.isEnabled()); + } +} diff --git a/security-csrf/src/test/java/io/micronaut/security/csrf/CsrfConfigurationPropertiesTest.java b/security-csrf/src/test/java/io/micronaut/security/csrf/CsrfConfigurationPropertiesTest.java new file mode 100644 index 0000000000..e12cc053a2 --- /dev/null +++ b/security-csrf/src/test/java/io/micronaut/security/csrf/CsrfConfigurationPropertiesTest.java @@ -0,0 +1,48 @@ +package io.micronaut.security.csrf; + +import io.micronaut.context.annotation.Property; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.cookie.SameSite; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.*; + +@Property(name = "micronaut.security.csrf.header-name", value = "header-foo") +@Property(name = "micronaut.security.csrf.field-name", value = "field-foo") +@Property(name = "micronaut.security.csrf.random-value-size", value = "5") +@Property(name = "micronaut.security.csrf.http-session-name", value = "session-foo") +@Property(name = "micronaut.security.csrf.cookie-domain", value = "cookie-domain-foo") +@Property(name = "micronaut.security.csrf.cookie-secure", value = StringUtils.FALSE) +@Property(name = "micronaut.security.csrf.cookie-path", value = "cookie-path-foo") +@Property(name = "micronaut.security.csrf.cookie-http-only", value = StringUtils.FALSE) +@Property(name = "micronaut.security.csrf.cookie-max-age", value = "5s") +@Property(name = "micronaut.security.csrf.cookie-name", value = "cookie-name-foo") +@Property(name = "micronaut.security.csrf.cookie-same-site", value = "Lax") +@Property(name = "micronaut.security.csrf.signature-key", value = "signature-key-foo") +@MicronautTest(startApplication = false) +class CsrfConfigurationPropertiesTest { + @Test + void settingCsrfConfiguration(CsrfConfiguration csrfConfiguration) { + assertEquals("header-foo", csrfConfiguration.getHeaderName()); + assertEquals("field-foo", csrfConfiguration.getFieldName()); + assertEquals(5, csrfConfiguration.getRandomValueSize()); + assertEquals("session-foo" ,csrfConfiguration.getHttpSessionName()); + assertTrue(csrfConfiguration.getCookieDomain().isPresent()); + assertEquals("cookie-domain-foo", csrfConfiguration.getCookieDomain().get()); + assertTrue(csrfConfiguration.isCookieSecure().isPresent()); + assertFalse(csrfConfiguration.isCookieSecure().get()); + assertTrue(csrfConfiguration.getCookiePath().isPresent()); + assertEquals("cookie-path-foo", csrfConfiguration.getCookiePath().get()); + assertTrue(csrfConfiguration.isCookieHttpOnly().isPresent()); + assertFalse(csrfConfiguration.isCookieHttpOnly().get()); + assertTrue(csrfConfiguration.getCookieMaxAge().isPresent()); + assertEquals(Duration.ofSeconds(5), csrfConfiguration.getCookieMaxAge().get()); + assertEquals("cookie-name-foo", csrfConfiguration.getCookieName()); + assertTrue(csrfConfiguration.getCookieSameSite().isPresent()); + assertEquals(SameSite.Lax, csrfConfiguration.getCookieSameSite().get()); + assertEquals("signature-key-foo", csrfConfiguration.getSecretKey()); + } +} diff --git a/security-csrf/src/test/java/io/micronaut/security/csrf/CsrfConfigurationTest.java b/security-csrf/src/test/java/io/micronaut/security/csrf/CsrfConfigurationTest.java new file mode 100644 index 0000000000..c919d9d1a1 --- /dev/null +++ b/security-csrf/src/test/java/io/micronaut/security/csrf/CsrfConfigurationTest.java @@ -0,0 +1,87 @@ +package io.micronaut.security.csrf; + +import io.micronaut.http.cookie.SameSite; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; +import java.time.Duration; +import java.time.temporal.TemporalAmount; +import static org.junit.jupiter.api.Assertions.*; + +@MicronautTest(startApplication = false) +class CsrfConfigurationTest { + + @Inject + CsrfConfiguration csrfConfiguration; + + @Test + void defaultHeaderName() { + assertEquals("X-CSRF-TOKEN", csrfConfiguration.getHeaderName()); + } + + @Test + void defaultFieldName() { + assertEquals("csrfToken", csrfConfiguration.getFieldName()); + } + + @Test + void defaultEnabled() { + assertTrue(csrfConfiguration.isEnabled()); + } + + @Test + void defaultHttpSessionName() { + assertEquals("csrfToken", csrfConfiguration.getHttpSessionName()); + } + + @Test + void defaultRandomValueSize() { + assertEquals(16, csrfConfiguration.getRandomValueSize()); + } + + @Test + void defaultCookiePath() { + assertTrue(csrfConfiguration.getCookiePath().isPresent()); + assertEquals("/", csrfConfiguration.getCookiePath().get()); + } + + @Test + void defaultCookieName() { + assertEquals("__Host-csrfToken", csrfConfiguration.getCookieName()); + } + + @Test + void defaultSameSite() { + assertTrue(csrfConfiguration.getCookieSameSite().isPresent()); + assertEquals(SameSite.Strict, csrfConfiguration.getCookieSameSite().get()); + } + + @Test + void defaultCookieSecure() { + assertTrue(csrfConfiguration.isCookieSecure().isPresent()); + assertEquals(Boolean.TRUE, csrfConfiguration.isCookieSecure().get()); + } + + @Test + void defaultCookieHttpOnly() { + assertTrue(csrfConfiguration.isCookieHttpOnly().isPresent()); + assertEquals(Boolean.TRUE, csrfConfiguration.isCookieHttpOnly().get()); + } + + @Test + void defaultCookieDomain() { + assertTrue(csrfConfiguration.getCookieDomain().isEmpty()); + } + + @Test + void defaultCookieMaxAge() { + assertTrue(csrfConfiguration.getCookieMaxAge().isPresent()); + TemporalAmount expected = Duration.ofSeconds(3600); + assertEquals(expected, csrfConfiguration.getCookieMaxAge().get()); + } + + @Test + void defaultSecretKey() { + assertNull(csrfConfiguration.getSecretKey()); + } +} \ No newline at end of file diff --git a/security-csrf/src/test/java/io/micronaut/security/csrf/filter/CsrfFilterConfigurationPropertiesTest.java b/security-csrf/src/test/java/io/micronaut/security/csrf/filter/CsrfFilterConfigurationPropertiesTest.java new file mode 100644 index 0000000000..52381f0214 --- /dev/null +++ b/security-csrf/src/test/java/io/micronaut/security/csrf/filter/CsrfFilterConfigurationPropertiesTest.java @@ -0,0 +1,35 @@ +package io.micronaut.security.csrf.filter; + +import io.micronaut.context.annotation.Property; +import io.micronaut.http.HttpMethod; +import io.micronaut.http.MediaType; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import org.junit.jupiter.api.Test; +import java.util.Set; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +@Property(name = "micronaut.security.csrf.filter.regex-pattern", value = "^(?!\\/login).*$") +@Property(name = "micronaut.security.csrf.filter.methods[0]", value = "TRACE") +@Property(name = "micronaut.security.csrf.filter.methods[1]", value = "HEAD") +@Property(name = "micronaut.security.csrf.filter.content-types[0]", value = "application/xml") +@Property(name = "micronaut.security.csrf.filter.content-types[1]", value = "application/graphql") +@MicronautTest(startApplication = false) +class CsrfFilterConfigurationPropertiesTest { + @Test + void testFilterConfigurationSetting(CsrfFilterConfiguration configuration) { + assertEquals("^(?!\\/login).*$", + configuration.getRegexPattern()); + assertEquals(Set.of(HttpMethod.TRACE, HttpMethod.HEAD), + configuration.getMethods()); + assertEquals(Set.of(MediaType.APPLICATION_XML_TYPE, MediaType.APPLICATION_GRAPHQL_TYPE), + configuration.getContentTypes()); + } + + @Test + void csrfFilterSetEnabled() { + CsrfFilterConfigurationProperties configuration = new CsrfFilterConfigurationProperties(); + configuration.setEnabled(false); + assertFalse(configuration.isEnabled()); + } +} diff --git a/security-csrf/src/test/java/io/micronaut/security/csrf/filter/CsrfFilterConfigurationTest.java b/security-csrf/src/test/java/io/micronaut/security/csrf/filter/CsrfFilterConfigurationTest.java new file mode 100644 index 0000000000..3c77be63dd --- /dev/null +++ b/security-csrf/src/test/java/io/micronaut/security/csrf/filter/CsrfFilterConfigurationTest.java @@ -0,0 +1,66 @@ +package io.micronaut.security.csrf.filter; + +import io.micronaut.context.annotation.Property; +import io.micronaut.core.order.OrderUtil; +import io.micronaut.core.order.Ordered; +import io.micronaut.core.util.PathMatcher; +import io.micronaut.http.HttpMethod; +import io.micronaut.http.MediaType; +import io.micronaut.security.filters.SecurityFilter; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@Property(name = "micronaut.security.csrf.filter.regex-pattern", value = "^(?!\\/login).*$") +@MicronautTest(startApplication = false) +class CsrfFilterConfigurationTest { + + @Inject + CsrfFilterConfiguration csrfFilterConfiguration; + + @Inject + CsrfFilter csrfFilter; + + @Inject + SecurityFilter securityFilter; + + @Test + void orderOfFilters() { + List filters = new ArrayList<>(List.of(csrfFilter, securityFilter)); + OrderUtil.sort(filters); + assertInstanceOf(SecurityFilter.class, filters.get(0)); + + filters = new ArrayList<>(List.of(securityFilter, csrfFilter)); + OrderUtil.sort(filters); + assertInstanceOf(SecurityFilter.class, filters.get(0)); + } + + @Test + void defaultMethods() { + assertEquals(Set.of(HttpMethod.POST, HttpMethod.DELETE, HttpMethod.PUT, HttpMethod.PATCH), csrfFilterConfiguration.getMethods()); + } + + @Test + void defaultContentType() { + assertEquals(Set.of(MediaType.APPLICATION_FORM_URLENCODED_TYPE, MediaType.MULTIPART_FORM_DATA_TYPE), csrfFilterConfiguration.getContentTypes()); + } + + @Test + void regexPatternCanBeChanged() { + String regexPattern = csrfFilterConfiguration.getRegexPattern(); + assertFalse(PathMatcher.REGEX.matches(csrfFilterConfiguration.getRegexPattern(), "/login")); + assertTrue(PathMatcher.REGEX.matches(csrfFilterConfiguration.getRegexPattern(), "/todo/list")); + assertEquals("^(?!\\/login).*$", regexPattern); + } + + @Test + void defaultEnabled() { + assertTrue(csrfFilterConfiguration.isEnabled()); + } +} \ No newline at end of file diff --git a/security-csrf/src/test/java/io/micronaut/security/csrf/filter/CsrfFilterDisabledTest.java b/security-csrf/src/test/java/io/micronaut/security/csrf/filter/CsrfFilterDisabledTest.java new file mode 100644 index 0000000000..4d2151061b --- /dev/null +++ b/security-csrf/src/test/java/io/micronaut/security/csrf/filter/CsrfFilterDisabledTest.java @@ -0,0 +1,23 @@ +package io.micronaut.security.csrf.filter; + +import io.micronaut.context.BeanContext; +import io.micronaut.context.annotation.Property; +import io.micronaut.core.util.StringUtils; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +@Property(name = "micronaut.security.csrf.filter.enabled", value = StringUtils.FALSE) +@MicronautTest(startApplication = false) +class CsrfFilterDisabledTest { + + @Inject + BeanContext beanContext; + + @Test + void testFieldCsrfTokenResolverDisabled() { + assertFalse(beanContext.containsBean(CsrfFilter.class)); + } +} diff --git a/security-csrf/src/test/java/io/micronaut/security/csrf/filter/CsrfFilterTest.java b/security-csrf/src/test/java/io/micronaut/security/csrf/filter/CsrfFilterTest.java new file mode 100644 index 0000000000..134af5625c --- /dev/null +++ b/security-csrf/src/test/java/io/micronaut/security/csrf/filter/CsrfFilterTest.java @@ -0,0 +1,153 @@ +package io.micronaut.security.csrf.filter; + +import io.micronaut.context.annotation.Property; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.type.Argument; +import io.micronaut.core.type.ReturnType; +import io.micronaut.http.HttpMethod; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.bind.RequestBinderRegistry; +import io.micronaut.http.simple.SimpleHttpRequest; +import io.micronaut.http.uri.UriMatchVariable; +import io.micronaut.inject.ExecutableMethod; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.micronaut.web.router.UriRouteInfo; +import io.micronaut.web.router.UriRouteMatch; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +@Property(name = "micronaut.security.csrf.filter.regex-pattern", value = "^(?!\\/login).*$") +@MicronautTest(startApplication = false) +class CsrfFilterTest { + + @Test + void csrfFilterUriMatch(CsrfFilter csrfFilter) { + assertFalse(csrfFilter.shouldTheFilterProcessTheRequestAccordingToTheUriMatch("/login")); + assertTrue(csrfFilter.shouldTheFilterProcessTheRequestAccordingToTheUriMatch("/todo/list")); + + HttpRequest request = new SimpleHttpRequest<>(HttpMethod.POST, "/login", Collections.emptyMap()); + assertTrue(csrfFilter.shouldTheFilterProcessTheRequestAccordingToTheUriMatch(request)); + request = new SimpleHttpRequest<>(HttpMethod.POST, "/todo/list", Collections.emptyMap()); + assertTrue(csrfFilter.shouldTheFilterProcessTheRequestAccordingToTheUriMatch(request)); + + assertFalse(csrfFilter.shouldTheFilterProcessTheRequestAccordingToTheUriMatch(createUriRouteMatch("/login"))); + assertTrue(csrfFilter.shouldTheFilterProcessTheRequestAccordingToTheUriMatch(createUriRouteMatch("/todo/list"))); + } + + private static UriRouteMatch createUriRouteMatch(String uri) { + return new UriRouteMatch() { + @Override + public UriRouteInfo getRouteInfo() { + throw new UnsupportedOperationException(); + } + + @Override + public HttpMethod getHttpMethod() { + throw new UnsupportedOperationException(); + } + + @Override + public @NonNull ExecutableMethod getExecutableMethod() { + throw new UnsupportedOperationException(); + } + + @Override + public Object getTarget() { + throw new UnsupportedOperationException(); + } + + @Override + public Class getDeclaringType() { + throw new UnsupportedOperationException(); + } + + @Override + public Argument[] getArguments() { + throw new UnsupportedOperationException(); + } + + @Override + public Object invoke(Object... arguments) { + throw new UnsupportedOperationException(); + } + + @Override + public Method getTargetMethod() { + throw new UnsupportedOperationException(); + } + + @Override + public ReturnType getReturnType() { + throw new UnsupportedOperationException(); + } + + @Override + public String getMethodName() { + throw new UnsupportedOperationException(); + } + + @Override + public String getUri() { + return uri; + } + + @Override + public Map getVariableValues() { + throw new UnsupportedOperationException(); + } + + @Override + public List getVariables() { + throw new UnsupportedOperationException(); + } + + @Override + public Map getVariableMap() { + throw new UnsupportedOperationException(); + } + + @Override + public void fulfill(Map argumentValues) { + throw new UnsupportedOperationException(); + } + + @Override + public void fulfillBeforeFilters(RequestBinderRegistry requestBinderRegistry, HttpRequest request) { + throw new UnsupportedOperationException(); + + } + + @Override + public void fulfillAfterFilters(RequestBinderRegistry requestBinderRegistry, HttpRequest request) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isFulfilled() { + return false; + } + + @Override + public Optional> getRequiredInput(String name) { + throw new UnsupportedOperationException(); + } + + @Override + public Object execute() { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isSatisfied(String name) { + throw new UnsupportedOperationException(); + } + }; + } +} \ No newline at end of file diff --git a/security-csrf/src/test/java/io/micronaut/security/csrf/generator/CsrfDoubleSubmitCookiePatternTest.java b/security-csrf/src/test/java/io/micronaut/security/csrf/generator/CsrfDoubleSubmitCookiePatternTest.java new file mode 100644 index 0000000000..ba4fa2c406 --- /dev/null +++ b/security-csrf/src/test/java/io/micronaut/security/csrf/generator/CsrfDoubleSubmitCookiePatternTest.java @@ -0,0 +1,177 @@ +package io.micronaut.security.csrf.generator; + +import io.micronaut.context.annotation.Property; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.*; +import io.micronaut.http.annotation.*; +import io.micronaut.http.client.BlockingHttpClient; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.http.cookie.Cookie; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.csrf.CsrfConfiguration; +import io.micronaut.security.csrf.repository.CsrfTokenRepository; +import io.micronaut.security.rules.SecurityRule; +import io.micronaut.security.session.SessionIdResolver; +import io.micronaut.security.testutils.authprovider.MockAuthenticationProvider; +import io.micronaut.security.testutils.authprovider.SuccessAuthenticationScenario; +import io.micronaut.security.token.cookie.TokenCookieConfigurationProperties; +import io.micronaut.security.utils.HMacUtils; +import io.micronaut.serde.annotation.Serdeable; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Singleton; +import org.junit.jupiter.api.Test; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static io.micronaut.security.csrf.generator.DefaultCsrfTokenGenerator.hmacMessagePayload; +import static org.junit.jupiter.api.Assertions.*; + +@Property(name = "micronaut.security.authentication", value = "cookie") +@Property(name = "micronaut.security.token.jwt.signatures.secret.generator.secret", value = "pleaseChangeThisSecretForANewOne") +@Property(name = "micronaut.security.csrf.signature-key", value = "pleaseChangeThisSecretForANewOnekoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow") +@Property(name = "micronaut.security.redirect.enabled", value = StringUtils.FALSE) +@Property(name = "micronaut.security.csrf.filter.regex-pattern", value = "^(?!\\/login).*$") +@Property(name = "spec.name", value = "CsrfDoubleSubmitCookiePatternTest") +@MicronautTest +class CsrfDoubleSubmitCookiePatternTest { + public static final String FIX_SESSION_ID = "123456789"; + + @Test + void loginSavesACsrfTokenInCookie(@Client("/") HttpClient httpClient, + CsrfConfiguration csrfConfiguration) throws NoSuchAlgorithmException, InvalidKeyException { + BlockingHttpClient client = httpClient.toBlocking(); + + HttpRequest loginRequest = HttpRequest.POST("/login",Map.of("username", "sherlock", "password", "password")) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_TYPE); + + HttpResponse loginRsp = assertDoesNotThrow(() -> client.exchange(loginRequest)); + assertEquals(HttpStatus.OK, loginRsp.getStatus()); + Optional cookieJwtOptional = loginRsp.getCookie("JWT"); + assertTrue(cookieJwtOptional.isPresent()); + Cookie cookieJwt = cookieJwtOptional.get(); + String csrfTokenCookieName = "__Host-csrfToken"; + Optional cookieCsrfTokenOptional = loginRsp.getCookie(csrfTokenCookieName); + assertTrue(cookieCsrfTokenOptional.isPresent()); + Cookie cookieCsrfToken = cookieCsrfTokenOptional.get(); + + // CSRF Only in the cookie, not in the request headers or field, request is denied + assertDenied(client, cookieJwt.getValue(), csrfTokenCookieName, new PasswordChange("sherlock", "evil"), cookieCsrfToken.getValue()); + + // CSRF Token in request and in cookie don't match, request is unauthorized + String csrfToken = "abcdefg"; + assertNotEquals(cookieCsrfToken.getValue(), csrfToken); + PasswordChangeForm formWithCsrfToken = new PasswordChangeForm("sherlock", "evil", csrfToken); + assertDenied(client, cookieJwt.getValue(), csrfTokenCookieName, formWithCsrfToken, cookieCsrfToken.getValue()); + + // CSRF Token with HMAC but not session id feed into HMAC calculation, request is unauthorized + String randomValue = "abcdefg"; + String hmac = HMacUtils.base64EncodedHmacSha256(randomValue, csrfConfiguration.getSecretKey()); + String csrfTokenCalculatedWithoutSessionId = hmac + "." + randomValue; + PasswordChangeForm body = new PasswordChangeForm("sherlock", "evil", csrfTokenCalculatedWithoutSessionId); + assertDenied(client, cookieJwt.getValue(), csrfTokenCookieName, body, csrfTokenCalculatedWithoutSessionId); + + String message = hmacMessagePayload(FIX_SESSION_ID, randomValue); + hmac = HMacUtils.base64EncodedHmacSha256(message, csrfConfiguration.getSecretKey()); + csrfToken = hmac + "." + randomValue; + assertOk(client, cookieJwt.getValue(), csrfTokenCookieName, csrfToken); + + // Even if you have the same session id and random value, the attacker cannot generate the same hmac as he does not have the same secret key + String evilSignatureKey = "evilAyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAowevil"; + csrfToken = HMacUtils.base64EncodedHmacSha256(message, evilSignatureKey) + "." + randomValue; + assertDenied(client, cookieJwt.getValue(), csrfTokenCookieName, new PasswordChangeForm("sherlock", "evil", csrfToken), csrfToken); + + // CSRF Token in request match token in cookie and hmac signature is valid. + csrfToken = cookieCsrfToken.getValue(); + assertOk(client, cookieJwt.getValue(), csrfTokenCookieName, csrfToken); + } + + private void assertDenied(BlockingHttpClient client, String cookieJwt, String csrfTokenCookieName, Object body, String csrfToken) { + HttpRequest request = HttpRequest.POST("/password/change", body) + .cookie(Cookie.of(TokenCookieConfigurationProperties.DEFAULT_COOKIENAME, cookieJwt)) + .cookie(Cookie.of(csrfTokenCookieName, csrfToken)) + .accept(MediaType.TEXT_HTML) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_TYPE); + HttpClientResponseException ex = assertThrows(HttpClientResponseException.class, () -> client.retrieve(request)); + assertEquals(HttpStatus.FORBIDDEN, ex.getStatus()); + } + + private void assertOk(BlockingHttpClient client, String cookieJwt, String csrfTokenCookieName, String csrfToken) { + PasswordChangeForm body = new PasswordChangeForm("sherlock", "evil", csrfToken); + HttpRequest request = HttpRequest.POST("/password/change", body) + .cookie(Cookie.of(TokenCookieConfigurationProperties.DEFAULT_COOKIENAME, cookieJwt)) + .cookie(Cookie.of(csrfTokenCookieName, csrfToken)) + .accept(MediaType.TEXT_HTML) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_TYPE); + HttpResponse response = assertDoesNotThrow(() -> client.exchange(request, String.class)); + assertEquals(HttpStatus.OK, response.getStatus()); + } + + @Requires(property = "spec.name", value = "CsrfDoubleSubmitCookiePatternTest") + @Singleton + static class MockSessionIdResolver implements SessionIdResolver> { + @Override + @NonNull + public Optional findSessionId(@NonNull HttpRequest request) { + return Optional.of(FIX_SESSION_ID); + } + } + + @Requires(property = "spec.name", value = "CsrfDoubleSubmitCookiePatternTest") + @Singleton + static class AuthenticationProviderUserPassword extends MockAuthenticationProvider { + AuthenticationProviderUserPassword() { + super(List.of(new SuccessAuthenticationScenario("sherlock"))); + } + } + + @Requires(property = "spec.name", value = "CsrfDoubleSubmitCookiePatternTest") + @Controller + static class PasswordChangeController { + @Secured(SecurityRule.IS_ANONYMOUS) + @Produces(MediaType.TEXT_HTML) + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Post("/password/change") + String changePassword(@Body PasswordChange passwordChangeForm) { + return passwordChangeForm.username; + } + } + + @Serdeable + record PasswordChange( + String username, + String password) { + } + + @Serdeable + record PasswordChangeForm( + String username, + String password, + String csrfToken) { + } + + @Requires(property = "spec.name", value = "CsrfDoubleSubmitCookiePatternTest") + @Controller("/csrf") + static class CsrfTokenEchoController { + + private final CsrfTokenRepository> csrfTokenRepository; + + CsrfTokenEchoController(CsrfTokenRepository> csrfTokenRepository) { + this.csrfTokenRepository = csrfTokenRepository; + } + + @Secured(SecurityRule.IS_ANONYMOUS) + @Produces(MediaType.TEXT_PLAIN) + @Get("/echo") + Optional echo(HttpRequest request) { + return csrfTokenRepository.findCsrfToken(request); + } + } +} \ No newline at end of file diff --git a/security-csrf/src/test/java/io/micronaut/security/csrf/generator/CsrfDoubleSubmitCookiePatternWithHeaderTest.java b/security-csrf/src/test/java/io/micronaut/security/csrf/generator/CsrfDoubleSubmitCookiePatternWithHeaderTest.java new file mode 100644 index 0000000000..9f7f6ba664 --- /dev/null +++ b/security-csrf/src/test/java/io/micronaut/security/csrf/generator/CsrfDoubleSubmitCookiePatternWithHeaderTest.java @@ -0,0 +1,186 @@ +package io.micronaut.security.csrf.generator; + +import io.micronaut.context.annotation.Property; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.*; +import io.micronaut.http.annotation.*; +import io.micronaut.http.client.BlockingHttpClient; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.http.cookie.Cookie; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.csrf.CsrfConfiguration; +import io.micronaut.security.csrf.repository.CsrfTokenRepository; +import io.micronaut.security.rules.SecurityRule; +import io.micronaut.security.session.SessionIdResolver; +import io.micronaut.security.testutils.authprovider.MockAuthenticationProvider; +import io.micronaut.security.testutils.authprovider.SuccessAuthenticationScenario; +import io.micronaut.security.token.cookie.TokenCookieConfigurationProperties; +import io.micronaut.security.utils.HMacUtils; +import io.micronaut.serde.annotation.Serdeable; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Singleton; +import org.junit.jupiter.api.Test; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static io.micronaut.security.csrf.generator.DefaultCsrfTokenGenerator.hmacMessagePayload; +import static org.junit.jupiter.api.Assertions.*; + +@Property(name = "micronaut.security.authentication", value = "cookie") +@Property(name = "micronaut.security.token.jwt.signatures.secret.generator.secret", value = "pleaseChangeThisSecretForANewOne") +@Property(name = "micronaut.security.csrf.signature-key", value = "pleaseChangeThisSecretForANewOnekoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow") +@Property(name = "micronaut.security.redirect.enabled", value = StringUtils.FALSE) +@Property(name = "micronaut.security.csrf.token-resolvers.field.enabled", value = StringUtils.FALSE) +@Property(name = "micronaut.security.csrf.filter.regex-pattern", value = "^(?!\\/login).*$") +@Property(name = "spec.name", value = "CsrfDoubleSubmitCookiePatternWithHeaderTest") +@MicronautTest +class CsrfDoubleSubmitCookiePatternWithHeaderTest { + public static final String FIX_SESSION_ID = "123456789"; + + @Test + void loginSavesACsrfTokenInCookie(@Client("/") HttpClient httpClient, + CsrfConfiguration csrfConfiguration) throws NoSuchAlgorithmException, InvalidKeyException { + BlockingHttpClient client = httpClient.toBlocking(); + + HttpRequest loginRequest = HttpRequest.POST("/login",Map.of("username", "sherlock", "password", "password")) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_TYPE); + + HttpResponse loginRsp = assertDoesNotThrow(() -> client.exchange(loginRequest)); + assertEquals(HttpStatus.OK, loginRsp.getStatus()); + Optional cookieJwtOptional = loginRsp.getCookie("JWT"); + assertTrue(cookieJwtOptional.isPresent()); + Cookie cookieJwt = cookieJwtOptional.get(); + String csrfTokenCookieName = "__Host-csrfToken"; + Optional cookieCsrfTokenOptional = loginRsp.getCookie(csrfTokenCookieName); + assertTrue(cookieCsrfTokenOptional.isPresent()); + Cookie cookieCsrfToken = cookieCsrfTokenOptional.get(); + + // CSRF Only in the cookie, not in the request headers or field, request is denied + String csrfTokenHeader = ""; + assertDenied(client, cookieJwt.getValue(), csrfTokenCookieName, new PasswordChange("sherlock", "evil"), cookieCsrfToken.getValue(), csrfTokenHeader); + + // CSRF Token in request and in cookie don't match, request is unauthorized + String csrfToken = "abcdefg"; + assertNotEquals(cookieCsrfToken.getValue(), csrfToken); + PasswordChange formWithCsrfToken = new PasswordChange("sherlock", "evil"); + csrfTokenHeader = csrfToken; + assertDenied(client, cookieJwt.getValue(), csrfTokenCookieName, formWithCsrfToken, cookieCsrfToken.getValue(), csrfTokenHeader); + + // CSRF Token with HMAC but not session id feed into HMAC calculation, request is unauthorized + String randomValue = "abcdefg"; + String hmac = HMacUtils.base64EncodedHmacSha256(randomValue, csrfConfiguration.getSecretKey()); + String csrfTokenCalculatedWithoutSessionId = hmac + "." + randomValue; + PasswordChange body = new PasswordChange("sherlock", "evil"); + assertDenied(client, cookieJwt.getValue(), csrfTokenCookieName, body, csrfTokenCalculatedWithoutSessionId, csrfTokenCalculatedWithoutSessionId); + + String message = hmacMessagePayload(FIX_SESSION_ID, randomValue); + hmac = HMacUtils.base64EncodedHmacSha256(message, csrfConfiguration.getSecretKey()); + csrfToken = hmac + "." + randomValue; + assertOk(client, cookieJwt.getValue(), csrfTokenCookieName, csrfToken); + + // Even if you have the same session id and random value, the attacker cannot generate the same hmac as he does not have the same secret key + String evilSignatureKey = "evilAyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAowevil"; + csrfToken = HMacUtils.base64EncodedHmacSha256(message, evilSignatureKey) + "." + randomValue; + assertDenied(client, cookieJwt.getValue(), csrfTokenCookieName, new PasswordChange("sherlock", "evil"), csrfToken, csrfToken); + // invalid csrf but content type not intercepted by the filter. + assertOk(client, cookieJwt.getValue(), csrfTokenCookieName, csrfToken, MediaType.APPLICATION_JSON_TYPE); + + // CSRF Token in request match token in cookie and hmac signature is valid. + csrfToken = cookieCsrfToken.getValue(); + assertOk(client, cookieJwt.getValue(), csrfTokenCookieName, csrfToken); + + // Default CSRF Token validator expects the CSRF token to have a dot + csrfToken= csrfToken.replace(".", ""); + assertDenied(client, cookieJwt.getValue(), csrfTokenCookieName, new PasswordChange("sherlock", "evil"), csrfToken, csrfToken); + } + + private void assertDenied(BlockingHttpClient client, String cookieJwt, String csrfTokenCookieName, PasswordChange body, String csrfToken, String csrfTokenHeader) { + HttpRequest request = HttpRequest.POST("/password/change", body) + .header("X-CSRF-TOKEN", csrfTokenHeader) + .cookie(Cookie.of(TokenCookieConfigurationProperties.DEFAULT_COOKIENAME, cookieJwt)) + .cookie(Cookie.of(csrfTokenCookieName, csrfToken)) + .accept(MediaType.TEXT_HTML) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_TYPE); + HttpClientResponseException ex = assertThrows(HttpClientResponseException.class, () -> client.retrieve(request)); + assertEquals(HttpStatus.FORBIDDEN, ex.getStatus()); + } + + private void assertOk(BlockingHttpClient client, String cookieJwt, String csrfTokenCookieName, String csrfToken) { + assertOk(client, cookieJwt, csrfTokenCookieName, csrfToken, MediaType.APPLICATION_FORM_URLENCODED_TYPE); + } + + private void assertOk(BlockingHttpClient client, String cookieJwt, String csrfTokenCookieName, String csrfToken, MediaType contentType) { + PasswordChange body = new PasswordChange("sherlock", "evil"); + HttpRequest request = HttpRequest.POST("/password/change", body) + .header("X-CSRF-TOKEN", csrfToken) + .cookie(Cookie.of(TokenCookieConfigurationProperties.DEFAULT_COOKIENAME, cookieJwt)) + .cookie(Cookie.of(csrfTokenCookieName, csrfToken)) + .accept(MediaType.TEXT_HTML) + .contentType(contentType); + + HttpResponse response = assertDoesNotThrow(() -> client.exchange(request, String.class)); + assertEquals(HttpStatus.OK, response.getStatus()); + } + + @Requires(property = "spec.name", value = "CsrfDoubleSubmitCookiePatternWithHeaderTest") + @Singleton + static class MockSessionIdResolver implements SessionIdResolver> { + @Override + @NonNull + public Optional findSessionId(@NonNull HttpRequest request) { + return Optional.of(FIX_SESSION_ID); + } + } + + @Requires(property = "spec.name", value = "CsrfDoubleSubmitCookiePatternWithHeaderTest") + @Singleton + static class AuthenticationProviderUserPassword extends MockAuthenticationProvider { + AuthenticationProviderUserPassword() { + super(List.of(new SuccessAuthenticationScenario("sherlock"))); + } + } + + @Requires(property = "spec.name", value = "CsrfDoubleSubmitCookiePatternWithHeaderTest") + @Controller + static class PasswordChangeController { + @Secured(SecurityRule.IS_ANONYMOUS) + @Produces(MediaType.TEXT_HTML) + @Consumes({ MediaType.APPLICATION_FORM_URLENCODED, MediaType.APPLICATION_JSON }) + @Post("/password/change") + String changePassword(@Body PasswordChange passwordChangeForm) { + return passwordChangeForm.username; + } + } + + @Serdeable + record PasswordChange( + String username, + String password) { + } + + @Requires(property = "spec.name", value = "CsrfDoubleSubmitCookiePatternWithHeaderTest") + @Controller("/csrf") + static class CsrfTokenEchoController { + + private final CsrfTokenRepository> csrfTokenRepository; + + CsrfTokenEchoController(CsrfTokenRepository> csrfTokenRepository) { + this.csrfTokenRepository = csrfTokenRepository; + } + + @Secured(SecurityRule.IS_ANONYMOUS) + @Produces(MediaType.TEXT_PLAIN) + @Get("/echo") + Optional echo(HttpRequest request) { + return csrfTokenRepository.findCsrfToken(request); + } + } +} diff --git a/security-csrf/src/test/java/io/micronaut/security/csrf/generator/CsrfTokenGeneratorTest.java b/security-csrf/src/test/java/io/micronaut/security/csrf/generator/CsrfTokenGeneratorTest.java new file mode 100644 index 0000000000..22a07be5e9 --- /dev/null +++ b/security-csrf/src/test/java/io/micronaut/security/csrf/generator/CsrfTokenGeneratorTest.java @@ -0,0 +1,28 @@ +package io.micronaut.security.csrf.generator; + +import io.micronaut.http.HttpMethod; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.simple.SimpleHttpRequest; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@MicronautTest(startApplication = false) +class CsrfTokenGeneratorTest { + + @Test + void generatedCsrfTokensAreUnique(CsrfTokenGenerator csrfTokenGenerator) { + int attempts = 100; + HttpRequest request = new SimpleHttpRequest<>(HttpMethod.POST, "/password/change", "usenrame=sherlock&password=123456"); + Set results = new HashSet<>(); + for (int i = 0; i < attempts; i++) { + results.add(csrfTokenGenerator.generateCsrfToken(request)); + } + assertEquals(attempts, results.size()); + } + +} \ No newline at end of file diff --git a/security-csrf/src/test/java/io/micronaut/security/csrf/resolver/CsrfTokenResolverTest.java b/security-csrf/src/test/java/io/micronaut/security/csrf/resolver/CsrfTokenResolverTest.java new file mode 100644 index 0000000000..34cb25be7b --- /dev/null +++ b/security-csrf/src/test/java/io/micronaut/security/csrf/resolver/CsrfTokenResolverTest.java @@ -0,0 +1,31 @@ +package io.micronaut.security.csrf.resolver; + +import io.micronaut.http.HttpRequest; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@MicronautTest(startApplication = false) +class CsrfTokenResolverTest { + + @Inject + List>> csrfTokenResolvers; + + @Inject + List>> futureCsrfTokenResolvers; + @Test + void csrfTokenResolversOrder() { + assertEquals(1, csrfTokenResolvers.size()); + assertEquals(1, futureCsrfTokenResolvers.size()); + List>> all = FutureCsrfTokenResolver.of(csrfTokenResolvers, futureCsrfTokenResolvers); + assertEquals(2, all.size()); + // It is important for HTTP Header to be the first one. FieldCsrfTokenResolver requires Netty. Moreover, it is more secure to supply the CSRF token via custom HTTP Header instead of a form field as it is more difficult to exploit. + assertInstanceOf(FutureCsrfTokenResolverAdapter.class, all.get(0)); // with HttpHeaderCsrfTokenResolver inside + assertInstanceOf(FieldCsrfTokenResolver.class, all.get(1)); + + } +} \ No newline at end of file diff --git a/security-csrf/src/test/java/io/micronaut/security/csrf/resolver/FieldCsrfTokenResolverDisabledTest.java b/security-csrf/src/test/java/io/micronaut/security/csrf/resolver/FieldCsrfTokenResolverDisabledTest.java new file mode 100644 index 0000000000..42571e06ef --- /dev/null +++ b/security-csrf/src/test/java/io/micronaut/security/csrf/resolver/FieldCsrfTokenResolverDisabledTest.java @@ -0,0 +1,24 @@ +package io.micronaut.security.csrf.resolver; + +import io.micronaut.context.BeanContext; +import io.micronaut.context.annotation.Property; +import io.micronaut.core.util.StringUtils; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +@Property(name = "micronaut.security.csrf.token-resolvers.field.enabled", value = StringUtils.FALSE) +@MicronautTest(startApplication = false) +class FieldCsrfTokenResolverDisabledTest { + + @Inject + BeanContext beanContext; + + @Test + void testFieldCsrfTokenResolverDisabled() { + assertFalse(beanContext.containsBean(FieldCsrfTokenResolver.class)); + } + +} \ No newline at end of file diff --git a/security-csrf/src/test/java/io/micronaut/security/csrf/resolver/FieldCsrfTokenResolverTest.java b/security-csrf/src/test/java/io/micronaut/security/csrf/resolver/FieldCsrfTokenResolverTest.java new file mode 100644 index 0000000000..471f8dab61 --- /dev/null +++ b/security-csrf/src/test/java/io/micronaut/security/csrf/resolver/FieldCsrfTokenResolverTest.java @@ -0,0 +1,80 @@ +package io.micronaut.security.csrf.resolver; + +import io.micronaut.context.BeanContext; +import io.micronaut.context.annotation.Property; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.http.HttpMethod; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.*; +import io.micronaut.http.client.BlockingHttpClient; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.simple.SimpleHttpRequest; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.csrf.generator.CsrfTokenGenerator; +import io.micronaut.security.csrf.repository.CsrfTokenRepository; +import io.micronaut.security.rules.SecurityRule; +import io.micronaut.serde.annotation.Serdeable; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Property(name = "spec.name", value = "FieldCsrfTokenResolverTest") +@MicronautTest +class FieldCsrfTokenResolverTest { + + @Inject + BeanContext beanContext; + + @Test + void fieldTokenResolver(@Client("/") HttpClient httpClient, + CsrfTokenGenerator> csrfTokenGenerator) { + BlockingHttpClient client = httpClient.toBlocking(); + String csrfToken = csrfTokenGenerator.generateCsrfToken(new SimpleHttpRequest<>(HttpMethod.POST,"/password/change", "username=sherlock&password=elementary")); + beanContext.registerSingleton(new CsrfTokenRepositoryReplacement(csrfToken)); + HttpRequest request = HttpRequest.POST("/password/change", "username=sherlock&csrfToken="+ csrfToken + "&password=elementary") + .contentType(MediaType.APPLICATION_FORM_URLENCODED_TYPE) + .accept(MediaType.TEXT_HTML); + String result = assertDoesNotThrow(() -> client.retrieve(request)); + assertEquals("sherlock", result); + } + + @Requires(property = "spec.name", value = "FieldCsrfTokenResolverTest") + static class CsrfTokenRepositoryReplacement implements CsrfTokenRepository> { + private final String csrfToken; + CsrfTokenRepositoryReplacement(String csrfToken) { + this.csrfToken = csrfToken; + } + @Override + @NonNull + public Optional findCsrfToken(@NonNull HttpRequest request) { + return Optional.of(csrfToken); + } + } + + @Requires(property = "spec.name", value = "FieldCsrfTokenResolverTest") + @Controller + static class PasswordChangeController { + @Secured(SecurityRule.IS_ANONYMOUS) + @Produces(MediaType.TEXT_HTML) + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Post("/password/change") + String changePassword(@Body PasswordChangeForm passwordChangeForm) { + return passwordChangeForm.username; + } + } + + @Serdeable + record PasswordChangeForm( + String username, + String password, + String csrfToken) { + } +} \ No newline at end of file diff --git a/security-csrf/src/test/java/io/micronaut/security/csrf/resolver/HttpHeaderCsrfTokenResolverDisabledTest.java b/security-csrf/src/test/java/io/micronaut/security/csrf/resolver/HttpHeaderCsrfTokenResolverDisabledTest.java new file mode 100644 index 0000000000..1bf5bfa70b --- /dev/null +++ b/security-csrf/src/test/java/io/micronaut/security/csrf/resolver/HttpHeaderCsrfTokenResolverDisabledTest.java @@ -0,0 +1,24 @@ +package io.micronaut.security.csrf.resolver; + +import io.micronaut.context.BeanContext; +import io.micronaut.context.annotation.Property; +import io.micronaut.core.util.StringUtils; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@Property(name = "micronaut.security.csrf.token-resolvers.http-header.enabled", value = StringUtils.FALSE) +@MicronautTest(startApplication = false) +class HttpHeaderCsrfTokenResolverDisabledTest { + + @Inject + BeanContext beanContext; + + @Test + void testHttpHeaderCsrfTokenResolverDisabled() { + assertFalse(beanContext.containsBean(HttpHeaderCsrfTokenResolver.class)); + } + +} \ No newline at end of file diff --git a/security-csrf/src/test/java/io/micronaut/security/csrf/resolver/HttpHeaderCsrfTokenResolverTest.java b/security-csrf/src/test/java/io/micronaut/security/csrf/resolver/HttpHeaderCsrfTokenResolverTest.java new file mode 100644 index 0000000000..d6fc3fa8a8 --- /dev/null +++ b/security-csrf/src/test/java/io/micronaut/security/csrf/resolver/HttpHeaderCsrfTokenResolverTest.java @@ -0,0 +1,68 @@ +package io.micronaut.security.csrf.resolver; + +import io.micronaut.context.annotation.Property; +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.client.BlockingHttpClient; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.rules.SecurityRule; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +@Property(name = "spec.name", value = "HttpHeaderCsrfTokenResolverTest") +@MicronautTest +class HttpHeaderCsrfTokenResolverTest { + + @Inject + @Client("/") + HttpClient httpClient; + + @Test + void csrfTokenCanBeResolvedInAnHttpHeader() { + BlockingHttpClient client = httpClient.toBlocking(); + String expected = "abcde"; + // uppercase header name + HttpRequest request = HttpRequest.GET("/csrf/echo").header("X-CSRF-TOKEN", expected); + String token = assertDoesNotThrow(() -> client.retrieve(request)); + assertEquals(expected, token); + + // lowercase header name + HttpRequest lowerCaseRequest = HttpRequest.GET("/csrf/echo").header("X-CSRF-TOKEN", expected); + token = assertDoesNotThrow(() -> client.retrieve(lowerCaseRequest)); + assertEquals(expected, token); + + HttpClientResponseException ex = assertThrows(HttpClientResponseException.class, () -> client.retrieve(HttpRequest.GET("/csrf/echo"))); + assertEquals(HttpStatus.NOT_FOUND, ex.getStatus()); + } + + @Requires(property = "spec.name", value = "HttpHeaderCsrfTokenResolverTest") + @Controller("/csrf") + static class CsrfTokenEchoController { + + private final HttpHeaderCsrfTokenResolver httpHeaderCsrfTokenResolver; + + CsrfTokenEchoController(HttpHeaderCsrfTokenResolver httpHeaderCsrfTokenResolver) { + this.httpHeaderCsrfTokenResolver = httpHeaderCsrfTokenResolver; + } + + @Secured(SecurityRule.IS_ANONYMOUS) + @Produces(MediaType.TEXT_PLAIN) + @Get("/echo") + Optional echo(HttpRequest request) { + return httpHeaderCsrfTokenResolver.resolveToken(request); + } + } +} \ No newline at end of file diff --git a/security-csrf/src/test/java/io/micronaut/security/csrf/session/CsrfSessionLogingHandlerTest.java b/security-csrf/src/test/java/io/micronaut/security/csrf/session/CsrfSessionLogingHandlerTest.java new file mode 100644 index 0000000000..67658030ca --- /dev/null +++ b/security-csrf/src/test/java/io/micronaut/security/csrf/session/CsrfSessionLogingHandlerTest.java @@ -0,0 +1,127 @@ +package io.micronaut.security.csrf.session; + +import io.micronaut.context.annotation.Property; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.*; +import io.micronaut.http.annotation.*; +import io.micronaut.http.client.BlockingHttpClient; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.http.cookie.Cookie; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.csrf.repository.CsrfTokenRepository; +import io.micronaut.security.rules.SecurityRule; +import io.micronaut.security.testutils.authprovider.MockAuthenticationProvider; +import io.micronaut.security.testutils.authprovider.SuccessAuthenticationScenario; +import io.micronaut.serde.annotation.Serdeable; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Singleton; +import org.junit.jupiter.api.Test; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +@Property(name = "micronaut.security.authentication", value = "session") +@Property(name = "micronaut.security.redirect.enabled", value = StringUtils.FALSE) +@Property(name = "micronaut.security.csrf.filter.regex-pattern", value = "^(?!\\/login).*$") +@Property(name = "spec.name", value = "CsrfSessionLogingHandlerTest") +@MicronautTest +class CsrfSessionLogingHandlerTest { + + @Test + void loginSavesACsrfTokenInSession(@Client("/") HttpClient httpClient) { + BlockingHttpClient client = httpClient.toBlocking(); + HttpRequest csrfEcho = HttpRequest.GET("/csrf/echo"); + HttpClientResponseException ex = assertThrows(HttpClientResponseException.class, () -> client.retrieve(csrfEcho)); + assertEquals(HttpStatus.NOT_FOUND, ex.getStatus()); + + HttpRequest loginRequest = HttpRequest.POST("/login",Map.of("username", "sherlock", "password", "password")) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_TYPE); + + HttpResponse loginRsp = assertDoesNotThrow(() -> client.exchange(loginRequest)); + assertEquals(HttpStatus.OK, loginRsp.getStatus()); + String cookie = loginRsp.getHeaders().get(HttpHeaders.SET_COOKIE); + assertNotNull(cookie); + assertTrue(cookie.contains("SESSION=")); + assertTrue(cookie.contains("; HTTPOnly")); + String sessionId = cookie.split(";")[0].split("=")[1]; + assertNotNull(sessionId); + HttpRequest csrfEchoRequestWithSession = HttpRequest.GET("/csrf/echo").cookie(Cookie.of("SESSION", sessionId)); + String csrfToken = assertDoesNotThrow(() -> client.retrieve(csrfEchoRequestWithSession)); + assertNotNull(csrfToken); + + PasswordChange form = new PasswordChange("sherlock", "evil"); + HttpRequest passwordChangeRequestNoSessionCookie = HttpRequest.POST("/password/change", form) + .accept(MediaType.TEXT_HTML) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_TYPE); + ex = assertThrows(HttpClientResponseException.class, () -> client.retrieve(passwordChangeRequestNoSessionCookie)); + assertEquals(HttpStatus.UNAUTHORIZED, ex.getStatus()); + + PasswordChangeForm formWithCsrfToken = new PasswordChangeForm("sherlock", "evil", csrfToken); + HttpRequest passwordChangeRequestWithSessionCookie = HttpRequest.POST("/password/change", formWithCsrfToken) + .cookie(Cookie.of("SESSION", sessionId)) + .accept(MediaType.TEXT_HTML) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_TYPE); + HttpResponse passwordChangeRequestWithSessionCookieResponse = assertDoesNotThrow(() -> client.exchange(passwordChangeRequestWithSessionCookie, String.class)); + assertEquals(HttpStatus.OK, passwordChangeRequestWithSessionCookieResponse.getStatus()); + Optional htmlOptional = passwordChangeRequestWithSessionCookieResponse.getBody(); + assertTrue(htmlOptional.isPresent()); + assertEquals("sherlock", htmlOptional.get()); + } + + @Requires(property = "spec.name", value = "CsrfSessionLogingHandlerTest") + @Singleton + static class AuthenticationProviderUserPassword extends MockAuthenticationProvider { + AuthenticationProviderUserPassword() { + super(List.of(new SuccessAuthenticationScenario("sherlock"))); + } + } + + @Requires(property = "spec.name", value = "CsrfSessionLogingHandlerTest") + @Controller + static class PasswordChangeController { + @Secured(SecurityRule.IS_ANONYMOUS) + @Produces(MediaType.TEXT_HTML) + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Post("/password/change") + String changePassword(@Body PasswordChange passwordChangeForm) { + return passwordChangeForm.username; + } + } + + @Serdeable + record PasswordChange( + String username, + String password) { + } + + @Serdeable + record PasswordChangeForm( + String username, + String password, + String csrfToken) { + } + + @Requires(property = "spec.name", value = "CsrfSessionLogingHandlerTest") + @Controller("/csrf") + static class CsrfTokenEchoController { + + private final CsrfTokenRepository> csrfTokenRepository; + + CsrfTokenEchoController(CsrfTokenRepository> csrfTokenRepository) { + this.csrfTokenRepository = csrfTokenRepository; + } + + @Secured(SecurityRule.IS_ANONYMOUS) + @Produces(MediaType.TEXT_PLAIN) + @Get("/echo") + Optional echo(HttpRequest request) { + return csrfTokenRepository.findCsrfToken(request); + } + } +} \ No newline at end of file diff --git a/security-csrf/src/test/resources/logback.xml b/security-csrf/src/test/resources/logback.xml new file mode 100644 index 0000000000..67787a909e --- /dev/null +++ b/security-csrf/src/test/resources/logback.xml @@ -0,0 +1,11 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + \ No newline at end of file diff --git a/security-jwt/src/main/java/io/micronaut/security/token/jwt/validator/JsonWebTokenIdSessionIdResolver.java b/security-jwt/src/main/java/io/micronaut/security/token/jwt/validator/JsonWebTokenIdSessionIdResolver.java new file mode 100644 index 0000000000..ea01e168fb --- /dev/null +++ b/security-jwt/src/main/java/io/micronaut/security/token/jwt/validator/JsonWebTokenIdSessionIdResolver.java @@ -0,0 +1,56 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.token.jwt.validator; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.HttpRequest; +import io.micronaut.security.session.SessionIdResolver; +import jakarta.inject.Singleton; + +import java.util.Optional; + +import static io.micronaut.security.filters.SecurityFilter.TOKEN; +import static io.micronaut.security.token.Claims.TOKEN_ID; + +/** + * Implementation of {@link SessionIdResolver} that returns the jti claim JWT ID if a JWT token is associated with the request. + * + * @since 4.11.0 + * @author Sergio del Amo + */ +@Requires(property = SessionIdResolver.PREFIX + ".jwt-id.enabled", value = StringUtils.TRUE, defaultValue = StringUtils.TRUE) +@Requires(classes = HttpRequest.class) +@Requires(bean = JsonWebTokenParser.class) +@Singleton +@Internal +final class JsonWebTokenIdSessionIdResolver implements SessionIdResolver> { + private final JsonWebTokenParser jsonWebTokenParser; + + public JsonWebTokenIdSessionIdResolver(JsonWebTokenParser jsonWebTokenParser) { + this.jsonWebTokenParser = jsonWebTokenParser; + } + + @Override + @NonNull + public Optional findSessionId(@NonNull HttpRequest request) { + return request.getAttribute(TOKEN, String.class) + .flatMap(jsonWebTokenParser::parseClaims) + .flatMap(claims -> Optional.ofNullable(claims.get(TOKEN_ID)).map(Object::toString)); + } +} diff --git a/security-jwt/src/test/groovy/io/micronaut/security/token/jwt/validator/JsonWebTokenIdSessionIdResolverSpec.groovy b/security-jwt/src/test/groovy/io/micronaut/security/token/jwt/validator/JsonWebTokenIdSessionIdResolverSpec.groovy new file mode 100644 index 0000000000..c99a2542ca --- /dev/null +++ b/security-jwt/src/test/groovy/io/micronaut/security/token/jwt/validator/JsonWebTokenIdSessionIdResolverSpec.groovy @@ -0,0 +1,21 @@ +package io.micronaut.security.token.jwt.validator + +import io.micronaut.context.BeanContext +import io.micronaut.context.annotation.Property +import io.micronaut.core.util.StringUtils +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import spock.lang.Specification + +@Property(name = "micronaut.security.sessionid-resolver.jwt-id.enabled", value = StringUtils.FALSE) +@MicronautTest(startApplication = false) +class JsonWebTokenIdSessionIdResolverSpec extends Specification { + + @Inject + BeanContext beanContext + + void "it is possible to disable JsonWebTokenIdSessionIdResolver"() { + expect: + !beanContext.containsBean(JsonWebTokenIdSessionIdResolver) + } +} \ No newline at end of file diff --git a/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/token/response/IdTokenLoginHandler.java b/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/token/response/IdTokenLoginHandler.java index ef26a28487..777847b168 100644 --- a/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/token/response/IdTokenLoginHandler.java +++ b/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/token/response/IdTokenLoginHandler.java @@ -32,6 +32,8 @@ import io.micronaut.security.errors.PriorToLoginPersistence; import io.micronaut.security.token.cookie.AccessTokenCookieConfiguration; import io.micronaut.security.token.cookie.CookieLoginHandler; +import io.micronaut.security.token.cookie.LoginCookieProvider; +import jakarta.inject.Inject; import jakarta.inject.Singleton; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,6 +41,7 @@ import java.text.ParseException; import java.time.Duration; import java.util.ArrayList; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Map; @@ -57,18 +60,38 @@ public class IdTokenLoginHandler extends CookieLoginHandler { private static final Logger LOG = LoggerFactory.getLogger(IdTokenLoginHandler.class); + private final List>> loginCookieProviders; /** * @param accessTokenCookieConfiguration Access token cookie configuration * @param redirectConfiguration Redirect configuration * @param redirectService Redirect service * @param priorToLoginPersistence The prior to login persistence strategy + * @param loginCookieProviders List of beans of type {@link LoginCookieProvider} */ + @Inject public IdTokenLoginHandler(AccessTokenCookieConfiguration accessTokenCookieConfiguration, RedirectConfiguration redirectConfiguration, RedirectService redirectService, - @Nullable PriorToLoginPersistence, MutableHttpResponse> priorToLoginPersistence) { + @Nullable PriorToLoginPersistence, MutableHttpResponse> priorToLoginPersistence, + List>> loginCookieProviders) { super(accessTokenCookieConfiguration, redirectConfiguration, redirectService, priorToLoginPersistence); + this.loginCookieProviders = loginCookieProviders; + } + + /** + * @param accessTokenCookieConfiguration Access token cookie configuration + * @param redirectConfiguration Redirect configuration + * @param redirectService Redirect service + * @param priorToLoginPersistence The prior to login persistence strategy + * @deprecated Use {@link #IdTokenLoginHandler(AccessTokenCookieConfiguration, RedirectConfiguration, RedirectService, PriorToLoginPersistence, List)} instead. + */ + @Deprecated(forRemoval = true, since = "4.11.0") + public IdTokenLoginHandler(AccessTokenCookieConfiguration accessTokenCookieConfiguration, + RedirectConfiguration redirectConfiguration, + RedirectService redirectService, + @Nullable PriorToLoginPersistence, MutableHttpResponse> priorToLoginPersistence) { + this(accessTokenCookieConfiguration, redirectConfiguration, redirectService, priorToLoginPersistence, Collections.emptyList()); } /** @@ -83,6 +106,9 @@ public List getCookies(Authentication authentication, HttpRequest req jwtCookie.configure(accessTokenCookieConfiguration, request.isSecure()); jwtCookie.maxAge(cookieExpiration(authentication, request)); cookies.add(jwtCookie); + for (LoginCookieProvider> loginCookieProvider : loginCookieProviders) { + cookies.add(loginCookieProvider.provideCookie(request)); + } return cookies; } diff --git a/security-session/src/main/java/io/micronaut/security/session/DefaultSessionPopulator.java b/security-session/src/main/java/io/micronaut/security/session/DefaultSessionPopulator.java new file mode 100644 index 0000000000..467b08a789 --- /dev/null +++ b/security-session/src/main/java/io/micronaut/security/session/DefaultSessionPopulator.java @@ -0,0 +1,42 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.session; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.filters.SecurityFilter; +import io.micronaut.session.Session; +import jakarta.inject.Singleton; + +/** + * Default Implementation of {@link SessionPopulator}. It adds the {@link Authentication} object to the session with the key {@link SecurityFilter#AUTHENTICATION}. + * @param Request + */ +@Singleton +@Internal +final class DefaultSessionPopulator implements SessionPopulator { + /** + * Adds the {@link Authentication} object to the session with the key {@link SecurityFilter#AUTHENTICATION}. + * @param request The request + * @param authentication The authenticated user. + * @param session The session + */ + @Override + public void populateSession(T request, @NonNull Authentication authentication, @NonNull Session session) { + session.put(SecurityFilter.AUTHENTICATION, authentication); + } +} diff --git a/security-session/src/main/java/io/micronaut/security/session/HttpSessionSessionIdResolver.java b/security-session/src/main/java/io/micronaut/security/session/HttpSessionSessionIdResolver.java new file mode 100644 index 0000000000..c84bb8497a --- /dev/null +++ b/security-session/src/main/java/io/micronaut/security/session/HttpSessionSessionIdResolver.java @@ -0,0 +1,43 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.session; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.HttpRequest; +import io.micronaut.session.Session; +import io.micronaut.session.http.SessionForRequest; +import jakarta.inject.Singleton; + +import java.util.Optional; + +/** + * Implementation of {@link SessionIdResolver} that returns {@link Session#getId()} if an HTTP session is associated with the request. + * @author Sergio del Amo + * @since 4.11.0 + */ +@Requires(property = SessionIdResolver.PREFIX + ".httpsession-id.enabled", value = StringUtils.TRUE, defaultValue = StringUtils.TRUE) +@Internal +@Singleton +final class HttpSessionSessionIdResolver implements SessionIdResolver> { + @Override + @NonNull + public Optional findSessionId(@NonNull HttpRequest request) { + return SessionForRequest.find(request).map(Session::getId); + } +} diff --git a/security-session/src/main/java/io/micronaut/security/session/SessionLoginHandler.java b/security-session/src/main/java/io/micronaut/security/session/SessionLoginHandler.java index 7a8bacf46f..546992e7e7 100644 --- a/security-session/src/main/java/io/micronaut/security/session/SessionLoginHandler.java +++ b/security-session/src/main/java/io/micronaut/security/session/SessionLoginHandler.java @@ -28,14 +28,15 @@ import io.micronaut.security.config.RedirectConfiguration; import io.micronaut.security.config.RedirectService; import io.micronaut.security.errors.PriorToLoginPersistence; -import io.micronaut.security.filters.SecurityFilter; import io.micronaut.security.handlers.RedirectingLoginHandler; import io.micronaut.session.Session; import io.micronaut.session.SessionStore; import io.micronaut.session.http.SessionForRequest; +import jakarta.inject.Inject; import jakarta.inject.Singleton; import java.net.URI; import java.net.URISyntaxException; +import java.util.List; import java.util.Optional; /** @@ -60,22 +61,44 @@ public class SessionLoginHandler implements RedirectingLoginHandler, MutableHttpResponse> priorToLoginPersistence; + private final List>> sessionPopulators; + /** * Constructor. * @param redirectConfiguration Redirect configuration * @param sessionStore The session store * @param priorToLoginPersistence The persistence to store the original url * @param redirectService Redirection Service + * @param sessionPopulators Session Populators */ + @Inject public SessionLoginHandler(RedirectConfiguration redirectConfiguration, SessionStore sessionStore, @Nullable PriorToLoginPersistence, MutableHttpResponse> priorToLoginPersistence, - RedirectService redirectService) { + RedirectService redirectService, + List>> sessionPopulators) { this.loginFailure = redirectConfiguration.isEnabled() ? redirectService.loginFailureUrl() : null; this.loginSuccess = redirectConfiguration.isEnabled() ? redirectService.loginSuccessUrl() : null; this.redirectConfiguration = redirectConfiguration; this.sessionStore = sessionStore; this.priorToLoginPersistence = priorToLoginPersistence; + this.sessionPopulators = sessionPopulators; + } + + /** + * Constructor. + * @param redirectConfiguration Redirect configuration + * @param sessionStore The session store + * @param priorToLoginPersistence The persistence to store the original url + * @param redirectService Redirection Service + * @deprecated Use {@link #SessionLoginHandler(RedirectConfiguration, SessionStore, PriorToLoginPersistence, RedirectService, List)} instead. + */ + @Deprecated(forRemoval = true, since = "4.11.0") + public SessionLoginHandler(RedirectConfiguration redirectConfiguration, + SessionStore sessionStore, + @Nullable PriorToLoginPersistence, MutableHttpResponse> priorToLoginPersistence, + RedirectService redirectService) { + this(redirectConfiguration, sessionStore, priorToLoginPersistence, redirectService, List.of(new DefaultSessionPopulator<>())); } @Override @@ -130,8 +153,13 @@ private ThrowingSupplier loginSuccessUriSupplier(@NonNu return uriSupplier; } + /** + * Saves the authentication in the session. + * @param authentication Authentication + * @param request HTTP Request + */ private void saveAuthenticationInSession(Authentication authentication, HttpRequest request) { Session session = SessionForRequest.find(request).orElseGet(() -> SessionForRequest.create(sessionStore, request)); - session.put(SecurityFilter.AUTHENTICATION, authentication); + sessionPopulators.forEach(sessionPopulator -> sessionPopulator.populateSession(request, authentication, session)); } } diff --git a/security-session/src/main/java/io/micronaut/security/session/SessionPopulator.java b/security-session/src/main/java/io/micronaut/security/session/SessionPopulator.java new file mode 100644 index 0000000000..c6ea9a3e2b --- /dev/null +++ b/security-session/src/main/java/io/micronaut/security/session/SessionPopulator.java @@ -0,0 +1,40 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.session; + +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.order.Ordered; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.session.Session; + +/** + * API that allows to populate the session after a successful login. You can create extra beans of type {@link SessionPopulator} to add extra data to the session. + * @author Sergio del Amo + * @since 4.11.0 + * @param Request + */ +public interface SessionPopulator extends Ordered { + + /** + * Populates the session. + * @param request The request + * @param authentication The authenticated user. + * @param session The session + */ + void populateSession(@NonNull T request, + @NonNull Authentication authentication, + @NonNull Session session); +} diff --git a/security-session/src/test/groovy/io/micronaut/docs/security/session/HttpSessionSessionIdResolverSpec.groovy b/security-session/src/test/groovy/io/micronaut/docs/security/session/HttpSessionSessionIdResolverSpec.groovy new file mode 100644 index 0000000000..54cee18173 --- /dev/null +++ b/security-session/src/test/groovy/io/micronaut/docs/security/session/HttpSessionSessionIdResolverSpec.groovy @@ -0,0 +1,22 @@ +package io.micronaut.docs.security.session + +import io.micronaut.context.BeanContext +import io.micronaut.context.annotation.Property +import io.micronaut.core.util.StringUtils +import io.micronaut.security.session.HttpSessionSessionIdResolver +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import spock.lang.Specification + +@Property(name = "micronaut.security.sessionid-resolver.httpsession-id.enabled", value = StringUtils.FALSE) +@MicronautTest(startApplication = false) +class HttpSessionSessionIdResolverSpec extends Specification { + + @Inject + BeanContext beanContext + + void "it is possible to disable JsonWebTokenIdSessionIdResolver"() { + expect: + !beanContext.containsBean(HttpSessionSessionIdResolver) + } +} \ No newline at end of file diff --git a/security-session/src/test/groovy/io/micronaut/docs/security/session/SessionAuthenticationNoRedirectSpec.groovy b/security-session/src/test/groovy/io/micronaut/docs/security/session/SessionAuthenticationNoRedirectSpec.groovy index 270d8c7455..049c790e21 100644 --- a/security-session/src/test/groovy/io/micronaut/docs/security/session/SessionAuthenticationNoRedirectSpec.groovy +++ b/security-session/src/test/groovy/io/micronaut/docs/security/session/SessionAuthenticationNoRedirectSpec.groovy @@ -4,12 +4,16 @@ import io.micronaut.context.annotation.Requires import io.micronaut.core.annotation.Nullable import io.micronaut.http.HttpRequest import io.micronaut.http.HttpResponse +import io.micronaut.http.HttpStatus import io.micronaut.http.MediaType import io.micronaut.http.annotation.Controller import io.micronaut.http.annotation.Get import io.micronaut.http.annotation.Produces +import io.micronaut.http.client.exceptions.HttpClientResponseException import io.micronaut.http.cookie.Cookie import io.micronaut.security.annotation.Secured +import io.micronaut.security.rules.SecurityRule +import io.micronaut.security.session.SessionIdResolver import io.micronaut.security.testutils.EmbeddedServerSpecification import io.micronaut.security.testutils.authprovider.MockAuthenticationProvider import io.micronaut.security.testutils.authprovider.SuccessAuthenticationScenario @@ -77,6 +81,23 @@ class SessionAuthenticationNoRedirectSpec extends EmbeddedServerSpecification { rsp.body() rsp.body().contains('sherlock') + when: + String sessionIdInResolver = client.retrieve(HttpRequest.GET('/session/id') + .accept(MediaType.TEXT_PLAIN) + .cookie(Cookie.of('SESSION', sessionId))) + + then: + noExceptionThrown() + sessionIdInResolver + + when: + client.exchange(HttpRequest.GET('/session/id') + .accept(MediaType.TEXT_PLAIN)) + + then: + HttpClientResponseException ex = thrown() + HttpStatus.NOT_FOUND == ex.status + when: HttpRequest logoutRequest = HttpRequest.POST('/logout', "").cookie(Cookie.of('SESSION', sessionId)) HttpResponse logoutRsp = client.exchange(logoutRequest, String) @@ -106,10 +127,23 @@ class SessionAuthenticationNoRedirectSpec extends EmbeddedServerSpecification { @Secured("isAnonymous()") @Controller("/") static class HomeController { + private final SessionIdResolver> sessionIdResolver + + HomeController(SessionIdResolver> sessionIdResolver) { + this.sessionIdResolver = sessionIdResolver + } + @Produces(MediaType.TEXT_PLAIN) @Get String index(@Nullable Principal principal) { return principal?.name ?: 'You are not logged in' } + + @Secured(SecurityRule.IS_ANONYMOUS) + @Produces(MediaType.TEXT_PLAIN) + @Get("/session/id") + Optional index(HttpRequest request) { + return sessionIdResolver.findSessionId(request) + } } } diff --git a/security/src/main/java/io/micronaut/security/session/CompositeSessionIdResolver.java b/security/src/main/java/io/micronaut/security/session/CompositeSessionIdResolver.java new file mode 100644 index 0000000000..4304e045fa --- /dev/null +++ b/security/src/main/java/io/micronaut/security/session/CompositeSessionIdResolver.java @@ -0,0 +1,52 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.session; + +import io.micronaut.context.annotation.Primary; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import jakarta.inject.Singleton; +import java.util.List; +import java.util.Optional; + +/** + * Composite Pattern implementation of {@link SessionIdResolver}. + * @see Composite Pattern + * @param Request + */ +@Internal +@Primary +@Singleton +final class CompositeSessionIdResolver implements SessionIdResolver { + + private final List> sessionIdResolvers; + + /** + * + * @param sessionIdResolvers List of session id resolvers + */ + public CompositeSessionIdResolver(List> sessionIdResolvers) { + this.sessionIdResolvers = sessionIdResolvers; + } + + @Override + @NonNull + public Optional findSessionId(@NonNull T request) { + return sessionIdResolvers.stream() + .flatMap(sessionIdResolver -> sessionIdResolver.findSessionId(request).stream()) + .findFirst(); + } +} diff --git a/security/src/main/java/io/micronaut/security/session/SessionIdResolver.java b/security/src/main/java/io/micronaut/security/session/SessionIdResolver.java new file mode 100644 index 0000000000..0a6ea05189 --- /dev/null +++ b/security/src/main/java/io/micronaut/security/session/SessionIdResolver.java @@ -0,0 +1,47 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.session; + + +import io.micronaut.core.annotation.Indexed; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.order.Ordered; +import io.micronaut.data.annotation.Index; +import io.micronaut.security.config.SecurityConfigurationProperties; + +import java.util.Optional; + +/** + * API to resolve a session id for a given request. A session ID could be an HTTP Session ID but also a JSON Web Token Identifier in a token based state-less authentication. + * @author Sergio del Amo + * @since 4.11.0 + * @param Request + */ +@Indexed(SessionIdResolver.class) +public interface SessionIdResolver extends Ordered { + /** + * Prefix used in SessionID resolver implementation.s. + */ + String PREFIX = SecurityConfigurationProperties.PREFIX + ".sessionid-resolver"; + + /** + * + * @param request Request + * @return Session ID for the given request. Empty if no session ID was found. + */ + @NonNull + Optional findSessionId(@NonNull T request); +} diff --git a/security/src/main/java/io/micronaut/security/session/package-info.java b/security/src/main/java/io/micronaut/security/session/package-info.java new file mode 100644 index 0000000000..666d1ebe9d --- /dev/null +++ b/security/src/main/java/io/micronaut/security/session/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * @author Sergio del Amo + * @since 4.11.0 + */ +package io.micronaut.security.session; diff --git a/security/src/main/java/io/micronaut/security/token/cookie/LoginCookieProvider.java b/security/src/main/java/io/micronaut/security/token/cookie/LoginCookieProvider.java new file mode 100644 index 0000000000..1d7947796d --- /dev/null +++ b/security/src/main/java/io/micronaut/security/token/cookie/LoginCookieProvider.java @@ -0,0 +1,30 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.token.cookie; + +import io.micronaut.core.annotation.NonNull; +import io.micronaut.http.cookie.Cookie; + +/** + * Provides a Cookie which will be included in the login response. + * @author Sergio del Amo + * @since 4.11.0 + * @param Request + */ +public interface LoginCookieProvider { + @NonNull + Cookie provideCookie(@NonNull T request); +} diff --git a/security/src/main/java/io/micronaut/security/token/cookie/TokenCookieLoginHandler.java b/security/src/main/java/io/micronaut/security/token/cookie/TokenCookieLoginHandler.java index 9f54a14dc1..31370997cf 100644 --- a/security/src/main/java/io/micronaut/security/token/cookie/TokenCookieLoginHandler.java +++ b/security/src/main/java/io/micronaut/security/token/cookie/TokenCookieLoginHandler.java @@ -31,10 +31,12 @@ import io.micronaut.security.token.generator.AccessRefreshTokenGenerator; import io.micronaut.security.token.generator.AccessTokenConfiguration; import io.micronaut.security.token.render.AccessRefreshToken; +import jakarta.inject.Inject; import jakarta.inject.Singleton; import java.time.Duration; import java.time.temporal.TemporalAmount; import java.util.ArrayList; +import java.util.Collections; import java.util.List; /** @@ -50,6 +52,7 @@ public class TokenCookieLoginHandler extends CookieLoginHandler { protected final AccessRefreshTokenGenerator accessRefreshTokenGenerator; protected final RefreshTokenCookieConfiguration refreshTokenCookieConfiguration; protected final AccessTokenConfiguration accessTokenConfiguration; + private final List>> loginCookieProviders; /** * @param redirectService Redirection Service @@ -59,7 +62,35 @@ public class TokenCookieLoginHandler extends CookieLoginHandler { * @param accessTokenConfiguration JWT Generator Configuration * @param accessRefreshTokenGenerator Access Refresh Token Generator * @param priorToLoginPersistence Prior To Login Persistence Mechanism + * @param loginCookieProviders Login Cookie Providers */ + @Inject + public TokenCookieLoginHandler(RedirectService redirectService, + RedirectConfiguration redirectConfiguration, + AccessTokenCookieConfiguration accessTokenCookieConfiguration, + RefreshTokenCookieConfiguration refreshTokenCookieConfiguration, + AccessTokenConfiguration accessTokenConfiguration, + AccessRefreshTokenGenerator accessRefreshTokenGenerator, + @Nullable PriorToLoginPersistence, MutableHttpResponse> priorToLoginPersistence, + List>> loginCookieProviders) { + super(accessTokenCookieConfiguration, redirectConfiguration, redirectService, priorToLoginPersistence); + this.refreshTokenCookieConfiguration = refreshTokenCookieConfiguration; + this.accessTokenConfiguration = accessTokenConfiguration; + this.accessRefreshTokenGenerator = accessRefreshTokenGenerator; + this.loginCookieProviders = loginCookieProviders; + } + + /** + * @param redirectService Redirection Service + * @param redirectConfiguration Redirect configuration + * @param accessTokenCookieConfiguration JWT Access Token Cookie Configuration + * @param refreshTokenCookieConfiguration Refresh Token Cookie Configuration + * @param accessTokenConfiguration JWT Generator Configuration + * @param accessRefreshTokenGenerator Access Refresh Token Generator + * @param priorToLoginPersistence Prior To Login Persistence Mechanism + * @deprecated Use {@link TokenCookieLoginHandler#TokenCookieLoginHandler(RedirectService, RedirectConfiguration, AccessTokenCookieConfiguration, RefreshTokenCookieConfiguration, AccessTokenConfiguration, AccessRefreshTokenGenerator, PriorToLoginPersistence, List)} instead. + */ + @Deprecated(forRemoval = true, since = "4.11.0") public TokenCookieLoginHandler(RedirectService redirectService, RedirectConfiguration redirectConfiguration, AccessTokenCookieConfiguration accessTokenCookieConfiguration, @@ -67,10 +98,7 @@ public TokenCookieLoginHandler(RedirectService redirectService, AccessTokenConfiguration accessTokenConfiguration, AccessRefreshTokenGenerator accessRefreshTokenGenerator, @Nullable PriorToLoginPersistence, MutableHttpResponse> priorToLoginPersistence) { - super(accessTokenCookieConfiguration, redirectConfiguration, redirectService, priorToLoginPersistence); - this.refreshTokenCookieConfiguration = refreshTokenCookieConfiguration; - this.accessTokenConfiguration = accessTokenConfiguration; - this.accessRefreshTokenGenerator = accessRefreshTokenGenerator; + this(redirectService, redirectConfiguration, accessTokenCookieConfiguration, refreshTokenCookieConfiguration, accessTokenConfiguration, accessRefreshTokenGenerator, priorToLoginPersistence, Collections.emptyList()); } @Override @@ -113,6 +141,9 @@ protected List getCookies(AccessRefreshToken accessRefreshToken, HttpReq cookies.add(refreshCookie); } + for (LoginCookieProvider> loginCookieProvider : loginCookieProviders) { + cookies.add(loginCookieProvider.provideCookie(request)); + } return cookies; } } diff --git a/security/src/main/java/io/micronaut/security/utils/HMacUtils.java b/security/src/main/java/io/micronaut/security/utils/HMacUtils.java new file mode 100644 index 0000000000..5302df366a --- /dev/null +++ b/security/src/main/java/io/micronaut/security/utils/HMacUtils.java @@ -0,0 +1,68 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.utils; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +/** + * Utility methods for HMAC. + * @author Sergio del Amo + * @since 4.11.0 + */ +@Internal +public final class HMacUtils { + private static final String HMAC_SHA256 = "HmacSHA256"; + + private HMacUtils() { + } + + /** + * + * @param data Data + * @param key Signature Key + * @return HMAC SHA-256 encoded in Base64 + * @throws NoSuchAlgorithmException if no {@code Provider} supports a {@code MacSpi} implementation for the specified algorithm. + * @throws InvalidKeyException if the given key is inappropriate for initializing this MAC. + */ + public static String base64EncodedHmacSha256(@NonNull String data, @NonNull String key) throws NoSuchAlgorithmException, InvalidKeyException { + return base64EncodedHmac(HMAC_SHA256, data, key); + } + + /** + * + * @param algorithm HMAC algorithm + * @param data Data + * @param key Signature Key + * @return HMAC encoded in Base64 + * @throws NoSuchAlgorithmException if no {@code Provider} supports a {@code MacSpi} implementation for the specified algorithm. + * @throws InvalidKeyException if the given key is inappropriate for initializing this MAC. + */ + public static String base64EncodedHmac(@NonNull String algorithm, @NonNull String data, @NonNull String key) + throws NoSuchAlgorithmException, InvalidKeyException { + SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), algorithm); + Mac mac = Mac.getInstance(algorithm); + mac.init(secretKeySpec); + return Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal(data.getBytes())); + } +} diff --git a/security/src/test/java/io/micronaut/security/utils/HMacUtilsTest.java b/security/src/test/java/io/micronaut/security/utils/HMacUtilsTest.java new file mode 100644 index 0000000000..627226f756 --- /dev/null +++ b/security/src/test/java/io/micronaut/security/utils/HMacUtilsTest.java @@ -0,0 +1,22 @@ +package io.micronaut.security.utils; + +import org.junit.jupiter.api.Test; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +import static org.junit.jupiter.api.Assertions.*; + +class HMacUtilsTest { + + @Test + void testHmacSha256() throws NoSuchAlgorithmException, InvalidKeyException { + String data = "abcdedf"; + String signatureKey = "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow"; + String hmac = HMacUtils.base64EncodedHmacSha256(data, signatureKey); + assertNotNull(hmac); + assertEquals(hmac, HMacUtils.base64EncodedHmacSha256(data, signatureKey)); + assertNotEquals(hmac, HMacUtils.base64EncodedHmacSha256("foobar", signatureKey)); + assertNotEquals(hmac, HMacUtils.base64EncodedHmacSha256(data, signatureKey + "evil")); + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 389c9dca0c..a76fc5b065 100644 --- a/settings.gradle +++ b/settings.gradle @@ -15,6 +15,7 @@ include "security-bom" include "security" include "security-aot" include "security-annotations" +include "security-csrf" include "security-jwt" include "security-session" include "security-ldap" diff --git a/src/main/docs/guide/csrf.adoc b/src/main/docs/guide/csrf.adoc new file mode 100644 index 0000000000..fc6d521ffa --- /dev/null +++ b/src/main/docs/guide/csrf.adoc @@ -0,0 +1,4 @@ +https://owasp.org/www-community/attacks/csrf[Cross-Site Request Forgery (CSRF)]: +____ +Cross-Site Request Forgery (CSRF) is an attack that forces an end user to execute unwanted actions on a web application in which they’re currently authenticated +____ diff --git a/src/main/docs/guide/csrf/csrfApis.adoc b/src/main/docs/guide/csrf/csrfApis.adoc new file mode 100644 index 0000000000..8906bfd7d5 --- /dev/null +++ b/src/main/docs/guide/csrf/csrfApis.adoc @@ -0,0 +1,9 @@ +The main APIs for CSRF protection are: + +* api:security.csrf.CsrfConfiguration[] +* api:security.csrf.filter.CsrfFilterConfiguration[] +* api:security.csrf.resolver.CsrfTokenResolver[] +* api:security.csrf.generator.CsrfTokenGenerator[] +* api:security.csrf.generator.CsrfHmacTokenGenerator[] +* api:security.csrf.validator.CsrfTokenValidator[] +* api:security.csrf.repository.CsrfTokenRepository[] diff --git a/src/main/docs/guide/csrf/csrfConfiguration.adoc b/src/main/docs/guide/csrf/csrfConfiguration.adoc new file mode 100644 index 0000000000..2e57da285c --- /dev/null +++ b/src/main/docs/guide/csrf/csrfConfiguration.adoc @@ -0,0 +1,3 @@ +The following configuration options are available for CSRF: + +include::{includedir}configurationProperties/io.micronaut.security.csrf.CsrfConfigurationProperties.adoc[] diff --git a/src/main/docs/guide/csrf/csrfDependency.adoc b/src/main/docs/guide/csrf/csrfDependency.adoc new file mode 100644 index 0000000000..cd75033f3b --- /dev/null +++ b/src/main/docs/guide/csrf/csrfDependency.adoc @@ -0,0 +1,3 @@ +Add the Micronaut Security CSRF dependency to protect against CSRF: + +dependency:micronaut-security-csrf[groupId=io.micronaut.security] diff --git a/src/main/docs/guide/csrf/csrfFilter.adoc b/src/main/docs/guide/csrf/csrfFilter.adoc new file mode 100644 index 0000000000..201b3c4fd0 --- /dev/null +++ b/src/main/docs/guide/csrf/csrfFilter.adoc @@ -0,0 +1,8 @@ +The core of Micronaut Security CSRF implementation is `io.micronaut.security.csrf.filter.CsrfFilter`. +A https://docs.micronaut.io/latest/guide/#filters[Server Filter] which attempts to resolve a CSRF Token with +every bean of type api:security.csrf.resolvers.CsrfTokenResolver[] and validates it with beans of type api:security.csrf.validator.CsrfTokenValidator[]. + +The following configuration options are available for the CSRF Filter: + +include::{includedir}configurationProperties/io.micronaut.security.csrf.filter.CsrfFilterConfigurationProperties.adoc[] + diff --git a/src/main/docs/guide/csrf/csrfMitigations.adoc b/src/main/docs/guide/csrf/csrfMitigations.adoc new file mode 100644 index 0000000000..5d34dede8a --- /dev/null +++ b/src/main/docs/guide/csrf/csrfMitigations.adoc @@ -0,0 +1,2 @@ +IMPORTANT: Ensure your application does not perform state-changing actions via the GET request method. Your application should perform state-changing actions only via POST, PUT, PATCH, or DELETE methods. + diff --git a/src/main/docs/guide/csrf/csrfMitigations/doubleSubmitCookiePattern.adoc b/src/main/docs/guide/csrf/csrfMitigations/doubleSubmitCookiePattern.adoc new file mode 100644 index 0000000000..385dacf171 --- /dev/null +++ b/src/main/docs/guide/csrf/csrfMitigations/doubleSubmitCookiePattern.adoc @@ -0,0 +1,21 @@ +https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#alternative-using-a-double-submit-cookie-pattern[Double Submit Cookie Pattern]. + +In a double-submit cookie pattern, the server generates a CSRF token, and it sends the CSRF token to the client in a cookie. + +Then, the server only needs to verify that following requests cookie's value matches the CSRF token sent in a request parameter (a hidden form field) or header. This process is stateless, as the server doesn’t need to store any information about the CSRF token. + +[source, bash] +---- +POST /transfer HTTP/1.1 +Host: vulnerable bank +Content-Type: application/x-www-form-urlencoded +Cookie: session=; __Host-csrfToken=o24b65486f506e2cd4403caf0d640024 +[...] + +amount=100&toUser=intended&csrfToken=o24b65486f506e2cd4403caf0d640024 +---- + +When you use Micronaut Security Authentication <>, or + <> a CSRF Token is saved in a Cookie upon login. + +You can <>. For example, by default the cookie name uses a `__Host-` https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#cookie_prefixes[Cookie prefix], can extend https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#using-cookies-with-host-prefixes-to-identify-origins[security protections against CSRF Attacks]. \ No newline at end of file diff --git a/src/main/docs/guide/csrf/csrfMitigations/signedDoubleSubmitCookiePattern.adoc b/src/main/docs/guide/csrf/csrfMitigations/signedDoubleSubmitCookiePattern.adoc new file mode 100644 index 0000000000..275902dbf4 --- /dev/null +++ b/src/main/docs/guide/csrf/csrfMitigations/signedDoubleSubmitCookiePattern.adoc @@ -0,0 +1,11 @@ +IMPORTANT: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#signed-double-submit-cookie-recommended[Signed Double-Submit Cookie]. Sign the CSRF Token with to prevent attackers from overriding the cookie value with their own (e.g. with taken-over subdomain attacks) . + +To do sign the CSRF Token, set the property `micronaut.security.csrf.signature-key`. + +[configuration] +---- +micronaut: + security: + csrf: + signature-key: pleaseChangeThisSecretForANewOnekoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow +---- \ No newline at end of file diff --git a/src/main/docs/guide/csrf/csrfMitigations/syncronizerTokenPattern.adoc b/src/main/docs/guide/csrf/csrfMitigations/syncronizerTokenPattern.adoc new file mode 100644 index 0000000000..a51f520cd1 --- /dev/null +++ b/src/main/docs/guide/csrf/csrfMitigations/syncronizerTokenPattern.adoc @@ -0,0 +1,7 @@ +https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#synchronizer-token-pattern[Syncronizer Token Pattern]. + +____ +In a synchronized token pattern, the server generates a CSRF token and shares it with the client before returning it, +usually through a hidden form parameter for the associated action. On form submission, the server checks the CSRF token against +one stored in the user’s session. If they match, the request is approved; otherwise, it’s rejected +____ diff --git a/src/main/docs/guide/csrf/csrfMitigations/syncronizerTokenPattern/csrfAndSession.adoc b/src/main/docs/guide/csrf/csrfMitigations/syncronizerTokenPattern/csrfAndSession.adoc new file mode 100644 index 0000000000..f5e4ab222f --- /dev/null +++ b/src/main/docs/guide/csrf/csrfMitigations/syncronizerTokenPattern/csrfAndSession.adoc @@ -0,0 +1,4 @@ +If you use <> and Micronaut Security CSRF, a CSRF token is automatically generated upon login and saved into the HTTP Session. <> provides an implementation +api:security.csrf.CsrfTokenRepository[] which fetches the CSRF token from the user's HTTP session. Thus, when the application sends new request to the sever with a CSRF token (e.g. in a hidden form field or HTTP Header), the server validates the supplied token against the value stored in the HTTP Session. + +You can disable the CSRF Session repository by setting `micronaut.security.csrf.repositories.session.enabled` to false. diff --git a/src/main/docs/guide/csrf/csrfTokenResolvers.adoc b/src/main/docs/guide/csrf/csrfTokenResolvers.adoc new file mode 100644 index 0000000000..2fb1ce1b77 --- /dev/null +++ b/src/main/docs/guide/csrf/csrfTokenResolvers.adoc @@ -0,0 +1 @@ +Micronaut Security CSRF resolves a CSRF Token with beans of type api:security.csrf.resolver.CsrfTokenResolver[] \ No newline at end of file diff --git a/src/main/docs/guide/csrf/csrfTokenResolvers/fieldCsrfTokenResolver.adoc b/src/main/docs/guide/csrf/csrfTokenResolvers/fieldCsrfTokenResolver.adoc new file mode 100644 index 0000000000..c15d7e809e --- /dev/null +++ b/src/main/docs/guide/csrf/csrfTokenResolvers/fieldCsrfTokenResolver.adoc @@ -0,0 +1,13 @@ +Micronaut Security CSRF ships a `io.micronaut.security.csrf.resolver.FieldCsrfTokenResolver` an implementation of api:security.csrf.resolver.CsrfTokenResolver[] which looks for a CSRF Token in a Request's form-url-encoded field. + +[source, bash] +---- +POST /transfer HTTP/1.1 +Host: vulnerable bank +Content-Type: application/x-www-form-urlencoded +Cookie: session= +[...] +amount=100&toUser=intended&csrfToken=o24b65486f506e2cd4403caf0d640024 +---- + +You can disable it by setting `micronaut.security.csrf.token-resolvers.field.enabled=false` diff --git a/src/main/docs/guide/csrf/csrfTokenResolvers/httpHeaderCsrfTokenResolver.adoc b/src/main/docs/guide/csrf/csrfTokenResolvers/httpHeaderCsrfTokenResolver.adoc new file mode 100644 index 0000000000..0da166e300 --- /dev/null +++ b/src/main/docs/guide/csrf/csrfTokenResolvers/httpHeaderCsrfTokenResolver.adoc @@ -0,0 +1,22 @@ +Micronaut Security CSRF ships a `io.micronaut.security.csrf.resolver.HttpHeaderCsrfTokenResolver` an implementation of api:security.csrf.resolver.CsrfTokenResolver[] which looks for a CSRF Token in a Request's HTTP Header. + +[source, bash] +---- +POST /transfer HTTP/1.1 +Host: vulnerable bank +Content-Type: application/x-www-form-urlencoded +Cookie: session= +X-CSRF-TOKEN: o24b65486f506e2cd4403caf0d640024 +[...] +amount=100&toUser=intended +---- + +You can disable it by setting `micronaut.security.csrf.token-resolvers.http-header.enabled=false` + +The HTTP Header name used by `HttpHeaderCsrfTokenResolver` <>. +It is recommended to use a custom HTTP Header Name. By using a custom HTTP Header name, it will not be possible to send them cross-origin without a permissive CORS implementation. + +Moreover, If possible, we recommend you to send the CSRF token via an HTTP Header instead of a form field as it is harder to attack. + +For example, https://turbo.hotwired.dev/handbook/frames#anti-forgery-support-(csrf)[Turbo] sends the CSRF token via a custom HTTP Header upon form submission. You can find information about https://micronaut-projects.github.io/micronaut-views/latest/guide/#turbo[Micronaut Turbo integration]. + diff --git a/src/main/docs/guide/endpoints/builtInHandlers.adoc b/src/main/docs/guide/endpoints/builtInHandlers.adoc index 98531c0392..6ae2739a0e 100644 --- a/src/main/docs/guide/endpoints/builtInHandlers.adoc +++ b/src/main/docs/guide/endpoints/builtInHandlers.adoc @@ -28,28 +28,5 @@ However, Micronaut security modules ship with several implementations which you These handlers allow you to set the following scenarios: -#### Micronaut Security Authentication Bearer - -When you set `micronaut.security.authentication=bearer`, api:security.token.bearer.AccessRefreshTokenLoginHandler[] a bean of type api:security.handlers.LoginHandler[] is enabled. - -image::micronaut-security-authentication-bearer.png[] - -#### Micronaut Security Authentication Cookie - -When you set `micronaut.security.authentication=cookie`, api:security.token.cookie.TokenCookieLoginHandler[] a bean of type api:security.handlers.LoginHandler[] and api:security.token.jwt.cookie.JwtCookieClearerLogoutHandler[] a bean of type api:security.handlers.LogoutHandler[] are enabled. - -image::micronaut-security-authentication-cookie.png[] - -#### Micronaut Security Authentication SessionLoginHandler - -When you set `micronaut.security.authentication=session`, api:security.session.SessionLoginHandler[] a bean of type api:security.handlers.LoginHandler[] and api:security.session.SessionLogoutHandler[] a bean of type api:security.handlers.LogoutHandler[] are enabled. - -image::micronaut-security-authentication-session.png[] - -#### Micronaut Security Authentication ID Token - -When you set `micronaut.security.authentication=idtoken`, api:security.oauth2.endpoint.token.response.IdTokenLoginHandler[] a bean of type api:security.handlers.LoginHandler[] and api:security.token.jwt.cookie.JwtCookieClearerLogoutHandler[] a bean of type api:security.handlers.LogoutHandler[] are enabled. - -image::micronaut-security-authentication-idtoken.png[] diff --git a/src/main/docs/guide/endpoints/builtInHandlers/micronautSecurityAuthenticationBearer.adoc b/src/main/docs/guide/endpoints/builtInHandlers/micronautSecurityAuthenticationBearer.adoc new file mode 100644 index 0000000000..02b45a8e1e --- /dev/null +++ b/src/main/docs/guide/endpoints/builtInHandlers/micronautSecurityAuthenticationBearer.adoc @@ -0,0 +1,3 @@ +When you set `micronaut.security.authentication=bearer`, api:security.token.bearer.AccessRefreshTokenLoginHandler[] a bean of type api:security.handlers.LoginHandler[] is enabled. + +image::micronaut-security-authentication-bearer.png[] \ No newline at end of file diff --git a/src/main/docs/guide/endpoints/builtInHandlers/micronautSecurityAuthenticationCookie.adoc b/src/main/docs/guide/endpoints/builtInHandlers/micronautSecurityAuthenticationCookie.adoc new file mode 100644 index 0000000000..37f0671c70 --- /dev/null +++ b/src/main/docs/guide/endpoints/builtInHandlers/micronautSecurityAuthenticationCookie.adoc @@ -0,0 +1,3 @@ +When you set `micronaut.security.authentication=cookie`, api:security.token.cookie.TokenCookieLoginHandler[] a bean of type api:security.handlers.LoginHandler[] and api:security.token.jwt.cookie.JwtCookieClearerLogoutHandler[] a bean of type api:security.handlers.LogoutHandler[] are enabled. + +image::micronaut-security-authentication-cookie.png[] \ No newline at end of file diff --git a/src/main/docs/guide/endpoints/builtInHandlers/micronautSecurityAuthenticationIdToken.adoc b/src/main/docs/guide/endpoints/builtInHandlers/micronautSecurityAuthenticationIdToken.adoc new file mode 100644 index 0000000000..396dbeab24 --- /dev/null +++ b/src/main/docs/guide/endpoints/builtInHandlers/micronautSecurityAuthenticationIdToken.adoc @@ -0,0 +1,3 @@ +When you set `micronaut.security.authentication=idtoken`, api:security.oauth2.endpoint.token.response.IdTokenLoginHandler[] a bean of type api:security.handlers.LoginHandler[] and api:security.token.jwt.cookie.JwtCookieClearerLogoutHandler[] a bean of type api:security.handlers.LogoutHandler[] are enabled. + +image::micronaut-security-authentication-idtoken.png[] \ No newline at end of file diff --git a/src/main/docs/guide/endpoints/builtInHandlers/micronautSecurityAuthenticationSession.adoc b/src/main/docs/guide/endpoints/builtInHandlers/micronautSecurityAuthenticationSession.adoc new file mode 100644 index 0000000000..685e5897ea --- /dev/null +++ b/src/main/docs/guide/endpoints/builtInHandlers/micronautSecurityAuthenticationSession.adoc @@ -0,0 +1,3 @@ +When you set `micronaut.security.authentication=session`, api:security.session.SessionLoginHandler[] a bean of type api:security.handlers.LoginHandler[] and api:security.session.SessionLogoutHandler[] a bean of type api:security.handlers.LogoutHandler[] are enabled. + +image::micronaut-security-authentication-session.png[] diff --git a/src/main/docs/guide/toc.yml b/src/main/docs/guide/toc.yml index e7a724eafd..41399b5421 100644 --- a/src/main/docs/guide/toc.yml +++ b/src/main/docs/guide/toc.yml @@ -33,7 +33,12 @@ endpoints: logout: title: Logout Controller logoutHandler: Logout Handler - builtInHandlers: Built-in Login and Logout Handlers + builtInHandlers: + title: Built-in Login and Logout Handlers + micronautSecurityAuthenticationBearer: Authentication Mode Bearer + micronautSecurityAuthenticationSession: Authentication Mode Session + micronautSecurityAuthenticationCookie: Authentication Mode Cookie + micronautSecurityAuthenticationIdToken: Authentication Mode ID Token securityConfiguration: title: Security Configuration rejectNotFound: Reject Not Found Routes @@ -83,6 +88,23 @@ authenticationStrategies: x509: X.509 Certificate Authentication custom: Custom Authorization Strategies rejection: Rejection Handling +csrf: + title: Cross-Site Request Forgery (CSRF) + csrfDependency: CSRF Dependency + csrfFilter: CSRF Filter + csrfMitigations: + title: CSRF Mitigations + syncronizerTokenPattern: + title: Syncronizer Token Pattern + csrfAndSession: CSRF and Session + doubleSubmitCookiePattern: Double Submit Cookie Pattern + signedDoubleSubmitCookiePattern: Signed Double Submit Cookie Pattern + csrfConfiguration: CSRF Configuration + csrfTokenResolvers: + title: CSRF Token Resolvers + httpHeaderCsrfTokenResolver: HTTP Header CSRF Token Resolution + fieldCsrfTokenResolver: Field CSRF Token Resolution + csrfApis: CSRF APIs tokenPropagation: Token Propagation tokenendpoints: title: Built-In Security Token Controllers diff --git a/test-suite-http/build.gradle b/test-suite-http/build.gradle index dc51cd9d7e..25b6b88b53 100644 --- a/test-suite-http/build.gradle +++ b/test-suite-http/build.gradle @@ -13,6 +13,7 @@ dependencies { testRuntimeOnly(mnLogging.logback.classic) testImplementation(projects.micronautSecurity) + testImplementation(projects.micronautSecurityCsrf) testImplementation(projects.micronautSecurityJwt) //testImplementation(projects.micronautSecurityOauth2) testImplementation(projects.micronautSecurityLdap)