diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java index e59d438193a..bb6e50bc76a 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java @@ -47,6 +47,7 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration; import org.springframework.security.config.annotation.web.configurers.AnonymousConfigurer; +import org.springframework.security.config.annotation.web.configurers.ApiKeyConfigurer; import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry; import org.springframework.security.config.annotation.web.configurers.ChannelSecurityConfigurer; @@ -1591,6 +1592,71 @@ public HttpSecurity oneTimeTokenLogin( return HttpSecurity.this; } + /** + * Configures API key authentication support. + * + *

Example Configuration

+ * + *
+	 * @Configuration
+	 * @EnableWebSecurity
+	 * public class SecurityConfig {
+	 *
+	 * 	@Bean
+	 * 	public ApiKeyDigest apiKeyDigest() {
+	 * 	 return new Sha3ApiKeyDigest();
+	 * 	}
+	 *
+	 * 	@Bean
+	 * 	public ApiKeySearchService apiKeySearchService(JdbcTemplate jdbc) {
+	 * 	 return new ApiKeySearchServiceImpl(jdbc);
+	 * 	}
+	 *
+	 * // separate filter chain for service-to-service requests
+	 * 	@Bean
+	 * 	@Order(1)
+	 * 	public SecurityFilterChain apiKeySecurityFilterChain(
+	 * 	 HttpSecurity http,
+	 * 	 ApiKeyDigest digest,
+	 * 	 ApiKeySearchService searchService
+	 * 	) throws Exception {
+	 * 		return http
+	 * 	 .securityMatcher("/s2s/do-something")
+	 * 			.authorizeHttpRequests((authorize) -> authorize
+	 * 					.anyRequest().authenticated()
+	 * 			)
+	 * 			.apiKey(configurer -> configurer
+	 * 		 	.digest(digest)
+	 * 		 	.searchService(searchService())
+	 * 			)
+	 * 		 // API key authentication is used for server-to-service interactions
+	 * 		 // which means there SHOULD be no browser, so no possibility for CSRF
+	 * 		 .csrf(AbstractHttpConfigurer::disable)
+	 * 		 .build();
+	 * 	}
+	 *
+	 * // filter chain for user requests
+	 * 	@Bean
+	 * 	@Order(2)
+	 * 	public SecurityFilterChain securityFilterChain(
+	 * 	 HttpSecurity http,
+	 * 	 ApiKeyDigest digest,
+	 * 	 ApiKeySearchService searchService
+	 * 	) throws Exception {
+	 * 	 // configure as usual
+	 * 	}
+	 *
+	 * }
+	 * 
+ * @param configurerCustomizer the {@link Customizer} to provide more options for the {@link ApiKeyConfigurer} + * @return the {@link HttpSecurity} for further customizations + * @throws Exception + */ + public HttpSecurity apiKey( Customizer> configurerCustomizer) throws Exception { + configurerCustomizer.customize(getOrApply(new ApiKeyConfigurer(getContext()))); + return HttpSecurity.this; + } + /** * Configures channel security. In order for this configuration to be useful at least * one mapping to a required channel must be provided. diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ApiKeyConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ApiKeyConfigurer.java new file mode 100644 index 00000000000..75b13798a2c --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ApiKeyConfigurer.java @@ -0,0 +1,148 @@ +package org.springframework.security.config.annotation.web.configurers; + +import java.time.Clock; +import java.util.Collection; +import java.util.Objects; + +import org.springframework.context.ApplicationContext; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.apikey.*; +import org.springframework.security.config.annotation.web.HttpSecurityBuilder; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.apikey.ApiKeyAuthenticationFilter; +import org.springframework.security.web.authentication.apikey.BearerTokenAuthenticationConverter; +import org.springframework.security.web.context.NullSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; + +/** + * Configures API key authentication. + * + * @author Alexey Razinkov + */ +public final class ApiKeyConfigurer> extends AbstractHttpConfigurer, H> { + + private final ApplicationContext context; + + private Clock clock = Clock.systemUTC(); + + private Converter> grantedAuthorityConverter = + new ApiKeySimpleGrantedAuthorityConverter(); + + private ApiKeySearchService searchService; + + private ApiKeyDigest digest; + + private AuthenticationConverter authnConverter = new BearerTokenAuthenticationConverter(); + + private SecurityContextRepository securityContextRepository = new NullSecurityContextRepository(); + + private AuthenticationSuccessHandler successHandler; + + private AuthenticationFailureHandler failureHandler; + + public ApiKeyConfigurer(final ApplicationContext context) { + this.context = Objects.requireNonNull(context); + } + + public ApiKeyConfigurer clock(final Clock clock) { + this.clock = Objects.requireNonNull(clock); + return this; + } + + public ApiKeyConfigurer grantedAuthorityConverter( + final Converter> converter + ) { + this.grantedAuthorityConverter = Objects.requireNonNull(converter); + return this; + } + + public ApiKeyConfigurer searchService(final ApiKeySearchService searchService) { + this.searchService = Objects.requireNonNull(searchService); + return this; + } + + public ApiKeyConfigurer digest(final ApiKeyDigest digest) { + this.digest = Objects.requireNonNull(digest); + return this; + } + + public ApiKeyConfigurer authenticationConverter(final AuthenticationConverter converter) { + this.authnConverter = Objects.requireNonNull(converter); + return this; + } + + public ApiKeyConfigurer securityContextRepository(final SecurityContextRepository securityContextRepository) { + this.securityContextRepository = Objects.requireNonNull(securityContextRepository); + return this; + } + + public ApiKeyConfigurer authenticationSuccessHandler(final AuthenticationSuccessHandler successHandler) { + this.successHandler = successHandler; + return this; + } + + public ApiKeyConfigurer authenticationFailureHandler(final AuthenticationFailureHandler failureHandler) { + this.failureHandler = failureHandler; + return this; + } + + @Override + public void init(final H builder) throws Exception { + super.init(builder); + final ApiKeySearchService searchService = getSearchService(); + final ApiKeyDigest digest = getDigest(); + final ApiKeyAuthenticationProvider authnProvider = new ApiKeyAuthenticationProvider( + searchService, + digest, + this.clock, + this.grantedAuthorityConverter + ); + builder.authenticationProvider(authnProvider); + } + + private ApiKeySearchService getSearchService() { + if (this.searchService != null) { + return this.searchService; + } + + final ApiKeySearchService bean = this.context.getBean(ApiKeySearchService.class); + if (bean == null) { + throw new IllegalStateException("API key search service required"); + } + + return bean; + } + + private ApiKeyDigest getDigest() { + if (this.digest != null) { + return this.digest; + } + + final ApiKeyDigest bean = this.context.getBean(ApiKeyDigest.class); + if (bean == null) { + throw new IllegalStateException("API key digest required"); + } + + return bean; + } + + @Override + public void configure(final H http) { + final AuthenticationManager authnManager = http.getSharedObject(AuthenticationManager.class); + final SecurityContextHolderStrategy securityContextHolderStrategy = getSecurityContextHolderStrategy(); + final ApiKeyAuthenticationFilter filter = new ApiKeyAuthenticationFilter( + authnManager, + this.authnConverter, + securityContextHolderStrategy, + this.securityContextRepository, + this.successHandler, + this.failureHandler + ); + http.addFilter(postProcess(filter)); + } +} diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/ApiKey.java b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKey.java new file mode 100644 index 00000000000..d5f2f8ba08f --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKey.java @@ -0,0 +1,132 @@ +/* + * Copyright 2004-present the original author or 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 org.springframework.security.authentication.apikey; + +import java.io.Serial; +import java.io.Serializable; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; +import java.util.Objects; +import java.util.function.Function; +import java.util.random.RandomGenerator; + +import org.springframework.util.Assert; + +/** + * API key that consists ID and secret parts. + *

+ * ID part allows efficiently searching API key information in some storage, such as + * relational database ({@link ApiKeySearchService} interface is used for this purpose). + * API key ID should not be used alone as it's prune to timing attacks (storages cannot + * use constant-time comparison because of efficiency requirements), so separate secret + * part is used. + *

+ * Secret part should be hashed before storing API key data just the same way as + * passwords, except that API keys do not require using specific slow hashing algorithms + * used for passwords (such BCrypt, Argon, etc.). + * + * @author Alexey Razinkov + */ +public final class ApiKey implements Serializable { + + @Serial + private static final long serialVersionUID = 5948279771096057355L; + + public static final SecureRandom RND = new SecureRandom(); + + public static final int DEFAULT_ID_BYTES_LENGTH = 16; + + public static final int DEFAULT_SECRET_BYTES_LENGTH = 16; + + public static ApiKey random() { + return random(RND, DEFAULT_ID_BYTES_LENGTH, DEFAULT_SECRET_BYTES_LENGTH); + } + + public static ApiKey random(final RandomGenerator random, final int idBytesLength, final int secretBytesLength) { + Objects.requireNonNull(random); + final byte[] idBytes = new byte[idBytesLength]; + final byte[] secretBytes = new byte[secretBytesLength]; + random.nextBytes(idBytes); + random.nextBytes(secretBytes); + return new ApiKey(idBytes, secretBytes); + } + + public static ApiKey parse(final String value) { + return parse(value, DEFAULT_ENCODER, DEFAULT_DECODER); + } + + public static ApiKey parse(final String value, final Function encoder, + final Function decoder) { + Assert.hasText(value, "API key must be provided"); + Objects.requireNonNull(encoder); + Objects.requireNonNull(decoder); + + final String[] parts = value.split("_", -1); + Assert.isTrue(parts.length == 2, "API key has invalid format"); + + final String apiKeyId = parts[0]; + Assert.hasText(apiKeyId, "API key has invalid format"); + + final String apiKeySecret = parts[1]; + Assert.hasText(apiKeySecret, "API key has invalid format"); + + return new ApiKey(apiKeyId, decoder.apply(apiKeySecret), encoder); + } + + private final String id; + + private final byte[] secret; + + private final Function encoder; + + private ApiKey(final byte[] id, final byte[] secret) { + this(DEFAULT_ENCODER.apply(id), secret, DEFAULT_ENCODER); + } + + private ApiKey(final String id, final byte[] secret, final Function encoder) { + Assert.hasText(id, "API key ID cannot be empty"); + Assert.isTrue(secret != null && secret.length> 0, "API key secret required"); + Objects.requireNonNull(encoder); + this.id = id; + this.secret = Arrays.copyOf(secret, secret.length); + this.encoder = encoder; + } + + public String getId() { + return this.id; + } + + public byte[] getSecret() { + return Arrays.copyOf(this.secret, this.secret.length); + } + + public String asToken() { + return this.id + '_' + this.encoder.apply(this.secret); + } + + @Override + public String toString() { + return "DefaultApiKey{id='" + this.id + '}'; + } + + private static final Function DEFAULT_ENCODER = Base64.getEncoder() + .withoutPadding()::encodeToString; + + private static final Function DEFAULT_DECODER = Base64.getDecoder()::decode; + +} diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyAuthenticationException.java b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyAuthenticationException.java new file mode 100644 index 00000000000..fdd29f67103 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyAuthenticationException.java @@ -0,0 +1,119 @@ +/* + * Copyright 2004-present the original author or 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 org.springframework.security.authentication.apikey; + +import java.time.Instant; + +import org.springframework.security.core.AuthenticationException; + +/** + * Base class for API key authentication exception. + * + * @author Alexey Razinkov + */ +public abstract sealed class ApiKeyAuthenticationException extends AuthenticationException { + + private ApiKeyAuthenticationException() { + super("API key authentication failed"); + } + + private ApiKeyAuthenticationException(final Throwable t) { + super("API key authentication failed", t); + } + + /** + * Thrown when failed to find stored API key with such ID. + */ + public static final class NotFound extends ApiKeyAuthenticationException { + + private final String apiKeyId; + + public NotFound(final String apiKeyId) { + this.apiKeyId = apiKeyId; + } + + public String getApiKeyId() { + return this.apiKeyId; + } + + } + + /** + * Thrown when API key is expired. + */ + public static final class Expired extends ApiKeyAuthenticationException { + + private final String apiKeyId; + + private final Instant expiredAt; + + private final Instant checkedAt; + + public Expired(final String apiKeyId, final Instant expiredAt, final Instant checkedAt) { + this.apiKeyId = apiKeyId; + this.expiredAt = expiredAt; + this.checkedAt = checkedAt; + } + + public String getApiKeyId() { + return this.apiKeyId; + } + + public Instant getExpiredAt() { + return this.expiredAt; + } + + public Instant getCheckedAt() { + return this.checkedAt; + } + + } + + /** + * Thrown when API key is expected as bearer token but no "Bearer" scheme found. + */ + public static final class MissingBearerScheme extends ApiKeyAuthenticationException { + + } + + /** + * Thrown when API key is expected as bearer token and "Bearer" scheme is present but + * token is missing. + */ + public static final class MissingBearerToken extends ApiKeyAuthenticationException { + + } + + /** + * Thrown when API key has invalid structure. + */ + public static final class Invalid extends ApiKeyAuthenticationException { + + private final String token; + + public Invalid(final String token, final Throwable cause) { + super(cause); + this.token = token; + } + + public String getToken() { + return this.token; + } + + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyAuthenticationProvider.java b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyAuthenticationProvider.java new file mode 100644 index 00000000000..c23c7b889a7 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyAuthenticationProvider.java @@ -0,0 +1,91 @@ +/* + * Copyright 2004-present the original author or 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 org.springframework.security.authentication.apikey; + +import java.time.Clock; +import java.time.Instant; +import java.util.Collection; +import java.util.Objects; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; + +/** + * API key authentication provider. + * + * @author Alexey Razinkov + */ +public final class ApiKeyAuthenticationProvider implements AuthenticationProvider { + + private final ApiKeySearchService searchService; + + private final ApiKeyDigest digest; + + private final Clock clock; + + private final Converter> grantedAuthorityConverter; + + public ApiKeyAuthenticationProvider(final ApiKeySearchService searchService, final ApiKeyDigest digest, + final Clock clock, final Converter> grantedAuthorityConverter) { + this.searchService = Objects.requireNonNull(searchService); + this.digest = Objects.requireNonNull(digest); + this.clock = Objects.requireNonNull(clock); + this.grantedAuthorityConverter = Objects.requireNonNull(grantedAuthorityConverter); + } + + @Override + public @Nullable Authentication authenticate(final Authentication authentication) throws AuthenticationException { + final ApiKeyToken apiKeyToken = (ApiKeyToken) authentication; + final ApiKey apiKey = apiKeyToken.getValue(); + + final StoredApiKey storedApiKey = this.searchService.findApiKeyHash(apiKey.getId()); + if (storedApiKey == null) { + // mitigating timing attack by comparing secret against some dummy hash + final String dummy = this.digest.getDummyDigest(); + this.digest.matches(apiKey.getSecret(), dummy); + + throw new ApiKeyAuthenticationException.NotFound(apiKey.getId()); + } + + if (!this.digest.matches(apiKey.getSecret(), storedApiKey.secretHash())) { + throw new BadCredentialsException("API key secret does not match"); + } + + final Instant expiresAt = storedApiKey.expiresAt(); + if (expiresAt != null) { + final Instant now = this.clock.instant(); + if (expiresAt.isBefore(now)) { + throw new ApiKeyAuthenticationException.Expired(apiKey.getId(), expiresAt, now); + } + } + + final Collection authorities = this.grantedAuthorityConverter.convert(storedApiKey); + return new AuthenticatedApiKeyToken(apiKey, authorities, apiKeyToken.getDetails()); + } + + @Override + public boolean supports(final Class authentication) { + return ApiKeyToken.class.isAssignableFrom(authentication); + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyDigest.java b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyDigest.java new file mode 100644 index 00000000000..599e2c31289 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyDigest.java @@ -0,0 +1,47 @@ +/* + * Copyright 2004-present the original author or 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 org.springframework.security.authentication.apikey; + +/** + * Handles API key hashing and encoding into string as well as securely comparing hashes. + * + * @author Alexey Razinkov + * @see org.springframework.security.crypto.password.PasswordEncoder + */ +public interface ApiKeyDigest { + + /** + * Hashes API key secret and encodes resulting byte array to string. + * @param apiKeySecret API key secret bytes + * @return Hash encoded into string + */ + String digest(byte[] apiKeySecret); + + /** + * Hashes provided API key secret and compares it against provided hash. + * @param apiKeySecret API key secret to hash + * @param digest Existing API key secret hash + * @return True if hash of provided API key secret matches existing hash + */ + boolean matches(byte[] apiKeySecret, String digest); + + /** + * @return Hash created of some dummy value for mitigating timing attack + */ + String getDummyDigest(); + +} diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeySearchService.java b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeySearchService.java new file mode 100644 index 00000000000..0a183eca645 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeySearchService.java @@ -0,0 +1,36 @@ +/* + * Copyright 2004-present the original author or 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 org.springframework.security.authentication.apikey; + +import org.jspecify.annotations.Nullable; + +/** + * Performs API key hash search in some storage (such as relational database). + * + * @author Alexey Razinkov + */ +@FunctionalInterface +public interface ApiKeySearchService { + + /** + * Searches for existing API key hash. + * @param apiKeyId API key ID + * @return Optional API key hash, never null + */ + @Nullable StoredApiKey findApiKeyHash(String apiKeyId); + +} diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeySimpleGrantedAuthorityConverter.java b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeySimpleGrantedAuthorityConverter.java new file mode 100644 index 00000000000..781349e4f17 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeySimpleGrantedAuthorityConverter.java @@ -0,0 +1,43 @@ +/* + * Copyright 2004-present the original author or 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 org.springframework.security.authentication.apikey; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +/** + * Converts API key claims to a collection of {@link SimpleGrantedAuthority}. + * + * @author Alexey Razinkov + */ +public class ApiKeySimpleGrantedAuthorityConverter implements Converter> { + + @Override + public Collection convert(StoredApiKey source) { + final List result = new ArrayList(); + for (final String claim : source.claims()) { + result.add(new SimpleGrantedAuthority(claim)); + } + return result; + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyToken.java b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyToken.java new file mode 100644 index 00000000000..31238e2bf56 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyToken.java @@ -0,0 +1,74 @@ +/* + * Copyright 2004-present the original author or 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 org.springframework.security.authentication.apikey; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.util.Assert; + +/** + * Represents unauthenticated API token. + * + * @author Alexey Razinkov + */ +public final class ApiKeyToken extends AbstractAuthenticationToken { + + private final ApiKey value; + + private final Object details; + + public ApiKeyToken(ApiKey value, Object details) { + super(null); + Assert.notNull(value, "API key must be provided"); + this.value = value; + this.details = details; + } + + public ApiKey getValue() { + return this.value; + } + + @Override + public @NonNull byte[] getCredentials() { + return this.value.getSecret(); + } + + @Override + public @NonNull String getPrincipal() { + return this.value.getId(); + } + + @Override + public @Nullable Object getDetails() { + return this.details; + } + + @Override + public void setDetails(@Nullable Object details) { + throw new UnsupportedOperationException(); + } + + @Override + public void setAuthenticated(boolean authenticated) { + if (authenticated) { + throw new IllegalArgumentException(); + } + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/AuthenticatedApiKeyToken.java b/core/src/main/java/org/springframework/security/authentication/apikey/AuthenticatedApiKeyToken.java new file mode 100644 index 00000000000..1bc04becfb8 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/apikey/AuthenticatedApiKeyToken.java @@ -0,0 +1,66 @@ +/* + * Copyright 2004-present the original author or 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 org.springframework.security.authentication.apikey; + +import java.util.Collection; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.util.Assert; + +/** + * Represents API key that successfully went through authentication process. + * + * @author Alexey Razinkov + */ +public final class AuthenticatedApiKeyToken extends AbstractAuthenticationToken { + + private final ApiKey value; + + /** + * Creates a token with the supplied array of authorities. + * @param value API key + * @param authorities the collection of GrantedAuthoritys for the principal + * represented by this authentication object. + */ + public AuthenticatedApiKeyToken(ApiKey value, @Nullable Collection authorities, + @Nullable Object details) { + super(authorities); + Assert.notNull(value, "API key must be provided"); + this.value = value; + setAuthenticated(true); + setDetails(details); + } + + public ApiKey getValue() { + return this.value; + } + + @Override + public @NonNull byte[] getCredentials() { + return this.value.getSecret(); + } + + @Override + public @NonNull String getPrincipal() { + return this.value.getId(); + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/Sha3ApiKeyDigest.java b/core/src/main/java/org/springframework/security/authentication/apikey/Sha3ApiKeyDigest.java new file mode 100644 index 00000000000..c988bb0d3f0 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/apikey/Sha3ApiKeyDigest.java @@ -0,0 +1,106 @@ +/* + * Copyright 2004-present the original author or 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 org.springframework.security.authentication.apikey; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.Objects; +import java.util.function.Function; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.util.Assert; + +/** + * {@link ApiKeyDigest} implementation via SHA3-256. + * + * @author Alexey Razinkov + */ +public class Sha3ApiKeyDigest implements ApiKeyDigest { + + private static final Log log = LogFactory.getLog(Sha3ApiKeyDigest.class); + + private static final String CODE = "{sha3_256}"; + + private final Function encoder; + + private final Function decoder; + + public Sha3ApiKeyDigest() { + this(Base64.getEncoder().withoutPadding()::encodeToString, Base64.getDecoder()::decode); + } + + public Sha3ApiKeyDigest(Function encoder, Function decoder) { + this.encoder = Objects.requireNonNull(encoder); + this.decoder = Objects.requireNonNull(decoder); + } + + @Override + public String digest(final byte[] apiKeySecret) { + Objects.requireNonNull(apiKeySecret); + final MessageDigest digest = createDigest(); + final byte[] secretHashBytes = digest.digest(apiKeySecret); + final String secretHash = this.encoder.apply(secretHashBytes); + return CODE + secretHash; + } + + @Override + public boolean matches(final byte[] apiKeySecret, final String hash) { + Objects.requireNonNull(apiKeySecret); + Objects.requireNonNull(hash); + Assert.isTrue(hash.startsWith(CODE), "Hash must start with " + CODE); + + final MessageDigest digest = createDigest(); + final byte[] actualSecretHash = digest.digest(apiKeySecret); + final String cleanHash = hash.substring(CODE.length()); + final byte[] expectedSecretHash = this.decoder.apply(cleanHash); + return MessageDigest.isEqual(expectedSecretHash, actualSecretHash); + } + + @Override + public String getDummyDigest() { + return this.encoder.apply(DummyHolder.VALUE); + } + + private static MessageDigest createDigest() { + try { + return MessageDigest.getInstance("SHA3-256"); + } + catch (final NoSuchAlgorithmException ex) { + throw new IllegalStateException(ex); + } + } + + /** + * Holds lazily initialized dummy hash value. + */ + private static final class DummyHolder { + + private static final byte[] VALUE; + + static { + log.debug("Creating dummy hash for mitigating timing attack"); + final MessageDigest digest = createDigest(); + final byte[] deadbeef = new byte[] { 0xD, 0xE, 0xA, 0xD, 0xB, 0xE, 0xE, 0xF, }; + VALUE = digest.digest(deadbeef); + } + + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/StoredApiKey.java b/core/src/main/java/org/springframework/security/authentication/apikey/StoredApiKey.java new file mode 100644 index 00000000000..3156fff4596 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/apikey/StoredApiKey.java @@ -0,0 +1,41 @@ +/* + * Copyright 2004-present the original author or 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 org.springframework.security.authentication.apikey; + +import java.time.Instant; +import java.util.Set; + +import org.jspecify.annotations.Nullable; + +import org.springframework.util.Assert; + +/** + * API key data stored somewhere (e.g., relational database). + * + * @author Alexey Razinkov + * @param id API key ID + * @param secretHash API key secret hash + * @param claims API key claim set, can be empty but never {@code null} + * @param expiresAt Optional expiration moment + */ +public record StoredApiKey(String id, String secretHash, Set claims, @Nullable Instant expiresAt) { + + public StoredApiKey { + Assert.hasText(secretHash, "API key secret hash must be provided"); + Assert.notNull(claims, "Claim set cannot be null"); + } +} diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/package-info.java b/core/src/main/java/org/springframework/security/authentication/apikey/package-info.java new file mode 100644 index 00000000000..aacbb10c9d4 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/apikey/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2004-present the original author or 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. + */ + +@NullMarked +package org.springframework.security.authentication.apikey; + +import org.jspecify.annotations.NullMarked; diff --git a/web/src/main/java/org/springframework/security/web/authentication/apikey/ApiKeyAuthenticationFilter.java b/web/src/main/java/org/springframework/security/web/authentication/apikey/ApiKeyAuthenticationFilter.java new file mode 100644 index 00000000000..8a6e46f6c00 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/apikey/ApiKeyAuthenticationFilter.java @@ -0,0 +1,90 @@ +package org.springframework.security.web.authentication.apikey; + +import java.io.IOException; +import java.util.Objects; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.Nullable; + +import org.springframework.core.log.LogMessage; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * @author Alexey Razinkov + */ +public class ApiKeyAuthenticationFilter extends OncePerRequestFilter { + + private final AuthenticationManager authnManager; + + private final AuthenticationConverter authnConverter; + + private final SecurityContextHolderStrategy securityContextHolderStrategy; + + private final SecurityContextRepository securityContextRepository; + + @Nullable private final AuthenticationSuccessHandler successHandler; + + @Nullable private final AuthenticationFailureHandler failureHandler; + + public ApiKeyAuthenticationFilter(AuthenticationManager authnManager, AuthenticationConverter authnConverter, + SecurityContextHolderStrategy securityContextHolderStrategy, + SecurityContextRepository securityContextRepository, @Nullable AuthenticationSuccessHandler successHandler, + @Nullable AuthenticationFailureHandler failureHandler) { + this.authnManager = Objects.requireNonNull(authnManager); + this.securityContextHolderStrategy = Objects.requireNonNull(securityContextHolderStrategy); + this.authnConverter = Objects.requireNonNull(authnConverter); + this.securityContextRepository = Objects.requireNonNull(securityContextRepository); + this.successHandler = successHandler; + this.failureHandler = failureHandler; + } + + @Override + protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, + final FilterChain filterChain) throws ServletException, IOException { + try { + final Authentication authRequest = this.authnConverter.convert(request); + if (authRequest == null) { + this.logger.trace("Did not process authentication request since failed to find API key token"); + filterChain.doFilter(request, response); + return; + } + final String apiKeyId = authRequest.getName(); + this.logger.trace(LogMessage.format("Found API key '%s'", apiKeyId)); + final Authentication authResult = this.authnManager.authenticate(authRequest); + final SecurityContext context = this.securityContextHolderStrategy.createEmptyContext(); + context.setAuthentication(authResult); + this.securityContextHolderStrategy.setContext(context); + if (this.logger.isDebugEnabled()) { + this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult)); + } + this.securityContextRepository.saveContext(context, request, response); + if (this.successHandler != null) { + this.successHandler.onAuthenticationSuccess(request, response, authRequest); + } + } + catch (final AuthenticationException ex) { + this.securityContextHolderStrategy.clearContext(); + this.logger.debug("Failed to process authentication request", ex); + if (this.failureHandler != null) { + this.failureHandler.onAuthenticationFailure(request, response, ex); + } + + return; + } + + filterChain.doFilter(request, response); + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/apikey/BearerTokenAuthenticationConverter.java b/web/src/main/java/org/springframework/security/web/authentication/apikey/BearerTokenAuthenticationConverter.java new file mode 100644 index 00000000000..4868c432285 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/apikey/BearerTokenAuthenticationConverter.java @@ -0,0 +1,73 @@ +package org.springframework.security.web.authentication.apikey; + +import java.util.Base64; +import java.util.Objects; +import java.util.function.Function; + +import jakarta.servlet.http.HttpServletRequest; +import org.jspecify.annotations.Nullable; + +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.AuthenticationDetailsSource; +import org.springframework.security.authentication.apikey.ApiKey; +import org.springframework.security.authentication.apikey.ApiKeyAuthenticationException; +import org.springframework.security.authentication.apikey.ApiKeyToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.util.StringUtils; + +/** + * @author Alexey Razinkov + */ +public final class BearerTokenAuthenticationConverter implements AuthenticationConverter { + + private final Function encoder; + + private final Function decoder; + + private final AuthenticationDetailsSource detailsSource; + + public BearerTokenAuthenticationConverter() { + this(Base64.getEncoder()::encodeToString, Base64.getDecoder()::decode, new WebAuthenticationDetailsSource()); + } + + public BearerTokenAuthenticationConverter(Function encoder, Function decoder, + AuthenticationDetailsSource detailsSource) { + this.encoder = Objects.requireNonNull(encoder); + this.decoder = Objects.requireNonNull(decoder); + this.detailsSource = Objects.requireNonNull(detailsSource); + } + + @Override + @Nullable public Authentication convert(final HttpServletRequest request) { + String headerValue = request.getHeader(HttpHeaders.AUTHORIZATION); + if (!StringUtils.hasText(headerValue)) { + return null; + } + + headerValue = headerValue.stripLeading(); + if (!headerValue.startsWith(SCHEME_PREFIX)) { + throw new ApiKeyAuthenticationException.MissingBearerScheme(); + } + + final String apiKeyToken = headerValue.substring(SCHEME_PREFIX.length()); + if (!StringUtils.hasText(apiKeyToken)) { + throw new ApiKeyAuthenticationException.MissingBearerToken(); + } + + final ApiKey apiKey; + try { + apiKey = ApiKey.parse(apiKeyToken, encoder, decoder); + } + catch (final Exception ex) { + throw new ApiKeyAuthenticationException.Invalid(apiKeyToken, ex); + } + + final Object details = this.detailsSource.buildDetails(request); + return new ApiKeyToken(apiKey, details); + } + + private static final String SCHEME_PREFIX = "Bearer "; + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/apikey/package-info.java b/web/src/main/java/org/springframework/security/web/authentication/apikey/package-info.java new file mode 100644 index 00000000000..1b1891d2e4b --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/apikey/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2004-present the original author or 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. + */ + +/** + * API key authentication implementation package. + */ +@NullMarked +package org.springframework.security.web.authentication.apikey; + +import org.jspecify.annotations.NullMarked;

AltStyle によって変換されたページ (->オリジナル) /