Skip to content

Commit 0573393

Browse files
committed
Initial API key core implementation
Signed-off-by: Alexey Razinkov <[email protected]>
1 parent 9126aaf commit 0573393

File tree

16 files changed

+1230
-0
lines changed

16 files changed

+1230
-0
lines changed

config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
4848
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration;
4949
import org.springframework.security.config.annotation.web.configurers.AnonymousConfigurer;
50+
import org.springframework.security.config.annotation.web.configurers.ApiKeyConfigurer;
5051
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
5152
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry;
5253
import org.springframework.security.config.annotation.web.configurers.ChannelSecurityConfigurer;
@@ -1572,6 +1573,72 @@ public HttpSecurity oneTimeTokenLogin(
15721573
return HttpSecurity.this;
15731574
}
15741575

1576+
/**
1577+
* Configures API key authentication support.
1578+
*
1579+
* <h2>Example Configuration</h2>
1580+
*
1581+
* <pre>
1582+
* &#064;Configuration
1583+
* &#064;EnableWebSecurity
1584+
* public class SecurityConfig {
1585+
*
1586+
* &#064;Bean
1587+
* public ApiKeyDigest apiKeyDigest() {
1588+
* return new Sha3ApiKeyDigest();
1589+
* }
1590+
*
1591+
* &#064;Bean
1592+
* public ApiKeySearchService apiKeySearchService(JdbcTemplate jdbc) {
1593+
* return new ApiKeySearchServiceImpl(jdbc);
1594+
* }
1595+
*
1596+
* // separate filter chain for service-to-service requests
1597+
* &#064;Bean
1598+
* &#064;Order(1)
1599+
* public SecurityFilterChain apiKeySecurityFilterChain(
1600+
* HttpSecurity http,
1601+
* ApiKeyDigest digest,
1602+
* ApiKeySearchService searchService
1603+
* ) throws Exception {
1604+
* return http
1605+
* .securityMatcher("/s2s/do-something")
1606+
* .authorizeHttpRequests((authorize) -&gt; authorize
1607+
* .anyRequest().authenticated()
1608+
* )
1609+
* .apiKey(configurer -> configurer
1610+
* .digest(digest)
1611+
* .searchService(searchService())
1612+
* )
1613+
* // API key authentication is used for server-to-service interactions
1614+
* // which means there SHOULD be no browser, so no possibility for CSRF
1615+
* .csrf(AbstractHttpConfigurer::disable)
1616+
* .build();
1617+
* }
1618+
*
1619+
* // filter chain for user requests
1620+
* &#064;Bean
1621+
* &#064;Order(2)
1622+
* public SecurityFilterChain securityFilterChain(
1623+
* HttpSecurity http,
1624+
* ApiKeyDigest digest,
1625+
* ApiKeySearchService searchService
1626+
* ) throws Exception {
1627+
* // configure as usual
1628+
* }
1629+
*
1630+
* }
1631+
* </pre>
1632+
* @param configurerCustomizer the {@link Customizer} to provide more options for the
1633+
* {@link ApiKeyConfigurer}
1634+
* @return the {@link HttpSecurity} for further customizations
1635+
* @throws Exception
1636+
*/
1637+
public HttpSecurity apiKey(Customizer<ApiKeyConfigurer<HttpSecurity>> configurerCustomizer) throws Exception {
1638+
configurerCustomizer.customize(getOrApply(new ApiKeyConfigurer<>(getContext())));
1639+
return HttpSecurity.this;
1640+
}
1641+
15751642
/**
15761643
* Configures channel security. In order for this configuration to be useful at least
15771644
* one mapping to a required channel must be provided.
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/*
2+
* Copyright 2004-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.config.annotation.web.configurers;
18+
19+
import java.time.Clock;
20+
import java.util.Collection;
21+
import java.util.Objects;
22+
23+
import org.springframework.context.ApplicationContext;
24+
import org.springframework.core.convert.converter.Converter;
25+
import org.springframework.security.authentication.AuthenticationManager;
26+
import org.springframework.security.authentication.apikey.ApiKeyAuthenticationProvider;
27+
import org.springframework.security.authentication.apikey.ApiKeyDigest;
28+
import org.springframework.security.authentication.apikey.ApiKeySearchService;
29+
import org.springframework.security.authentication.apikey.ApiKeySimpleGrantedAuthorityConverter;
30+
import org.springframework.security.authentication.apikey.StoredApiKey;
31+
import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
32+
import org.springframework.security.core.GrantedAuthority;
33+
import org.springframework.security.core.context.SecurityContextHolderStrategy;
34+
import org.springframework.security.web.authentication.AuthenticationConverter;
35+
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
36+
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
37+
import org.springframework.security.web.authentication.apikey.ApiKeyAuthenticationFilter;
38+
import org.springframework.security.web.authentication.apikey.BearerTokenAuthenticationConverter;
39+
import org.springframework.security.web.context.NullSecurityContextRepository;
40+
import org.springframework.security.web.context.SecurityContextRepository;
41+
42+
/**
43+
* Configures API key authentication.
44+
*
45+
* @author Alexey Razinkov
46+
*/
47+
public final class ApiKeyConfigurer<H extends HttpSecurityBuilder<H>>
48+
extends AbstractHttpConfigurer<ApiKeyConfigurer<H>, H> {
49+
50+
private final ApplicationContext context;
51+
52+
private Clock clock = Clock.systemUTC();
53+
54+
private Converter<StoredApiKey, Collection<GrantedAuthority>> grantedAuthorityConverter = new ApiKeySimpleGrantedAuthorityConverter();
55+
56+
private ApiKeySearchService searchService;
57+
58+
private ApiKeyDigest digest;
59+
60+
private AuthenticationConverter authnConverter = new BearerTokenAuthenticationConverter();
61+
62+
private SecurityContextRepository securityContextRepository = new NullSecurityContextRepository();
63+
64+
private AuthenticationSuccessHandler successHandler;
65+
66+
private AuthenticationFailureHandler failureHandler;
67+
68+
public ApiKeyConfigurer(final ApplicationContext context) {
69+
this.context = Objects.requireNonNull(context);
70+
}
71+
72+
public ApiKeyConfigurer<H> clock(final Clock clock) {
73+
this.clock = Objects.requireNonNull(clock);
74+
return this;
75+
}
76+
77+
public ApiKeyConfigurer<H> grantedAuthorityConverter(
78+
final Converter<StoredApiKey, Collection<GrantedAuthority>> converter) {
79+
this.grantedAuthorityConverter = Objects.requireNonNull(converter);
80+
return this;
81+
}
82+
83+
public ApiKeyConfigurer<H> searchService(final ApiKeySearchService searchService) {
84+
this.searchService = Objects.requireNonNull(searchService);
85+
return this;
86+
}
87+
88+
public ApiKeyConfigurer<H> digest(final ApiKeyDigest digest) {
89+
this.digest = Objects.requireNonNull(digest);
90+
return this;
91+
}
92+
93+
public ApiKeyConfigurer<H> authenticationConverter(final AuthenticationConverter converter) {
94+
this.authnConverter = Objects.requireNonNull(converter);
95+
return this;
96+
}
97+
98+
public ApiKeyConfigurer<H> securityContextRepository(final SecurityContextRepository securityContextRepository) {
99+
this.securityContextRepository = Objects.requireNonNull(securityContextRepository);
100+
return this;
101+
}
102+
103+
public ApiKeyConfigurer<H> authenticationSuccessHandler(final AuthenticationSuccessHandler successHandler) {
104+
this.successHandler = successHandler;
105+
return this;
106+
}
107+
108+
public ApiKeyConfigurer<H> authenticationFailureHandler(final AuthenticationFailureHandler failureHandler) {
109+
this.failureHandler = failureHandler;
110+
return this;
111+
}
112+
113+
@Override
114+
public void init(final H builder) {
115+
super.init(builder);
116+
final ApiKeySearchService searchService = getSearchService();
117+
final ApiKeyDigest digest = getDigest();
118+
final ApiKeyAuthenticationProvider authnProvider = new ApiKeyAuthenticationProvider(searchService, digest,
119+
this.clock, this.grantedAuthorityConverter);
120+
builder.authenticationProvider(authnProvider);
121+
}
122+
123+
private ApiKeySearchService getSearchService() {
124+
if (this.searchService != null) {
125+
return this.searchService;
126+
}
127+
128+
final ApiKeySearchService bean = this.context.getBean(ApiKeySearchService.class);
129+
if (bean == null) {
130+
throw new IllegalStateException("API key search service required");
131+
}
132+
133+
return bean;
134+
}
135+
136+
private ApiKeyDigest getDigest() {
137+
if (this.digest != null) {
138+
return this.digest;
139+
}
140+
141+
final ApiKeyDigest bean = this.context.getBean(ApiKeyDigest.class);
142+
if (bean == null) {
143+
throw new IllegalStateException("API key digest required");
144+
}
145+
146+
return bean;
147+
}
148+
149+
@Override
150+
public void configure(final H http) {
151+
final AuthenticationManager authnManager = http.getSharedObject(AuthenticationManager.class);
152+
final SecurityContextHolderStrategy securityContextHolderStrategy = getSecurityContextHolderStrategy();
153+
final ApiKeyAuthenticationFilter filter = new ApiKeyAuthenticationFilter(authnManager, this.authnConverter,
154+
securityContextHolderStrategy, this.securityContextRepository, this.successHandler,
155+
this.failureHandler);
156+
http.addFilter(postProcess(filter));
157+
}
158+
159+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/*
2+
* Copyright 2004-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.authentication.apikey;
18+
19+
import java.io.Serial;
20+
import java.io.Serializable;
21+
import java.security.SecureRandom;
22+
import java.util.Arrays;
23+
import java.util.Base64;
24+
import java.util.Objects;
25+
import java.util.function.Function;
26+
import java.util.random.RandomGenerator;
27+
28+
import org.springframework.util.Assert;
29+
30+
/**
31+
* API key that consists ID and secret parts.
32+
* <p>
33+
* ID part allows efficiently searching API key information in some storage, such as
34+
* relational database ({@link ApiKeySearchService} interface is used for this purpose).
35+
* API key ID should not be used alone as it's prune to timing attacks (storages cannot
36+
* use constant-time comparison because of efficiency requirements), so separate secret
37+
* part is used.
38+
* <p>
39+
* Secret part should be hashed before storing API key data just the same way as
40+
* passwords, except that API keys do not require using specific slow hashing algorithms
41+
* used for passwords (such BCrypt, Argon, etc.).
42+
*
43+
* @author Alexey Razinkov
44+
*/
45+
public final class ApiKey implements Serializable {
46+
47+
@Serial
48+
private static final long serialVersionUID = 5948279771096057355L;
49+
50+
public static final SecureRandom RND = new SecureRandom();
51+
52+
public static final int DEFAULT_ID_BYTES_LENGTH = 16;
53+
54+
public static final int DEFAULT_SECRET_BYTES_LENGTH = 16;
55+
56+
public static ApiKey random() {
57+
return random(RND, DEFAULT_ID_BYTES_LENGTH, DEFAULT_SECRET_BYTES_LENGTH);
58+
}
59+
60+
public static ApiKey random(final RandomGenerator random, final int idBytesLength, final int secretBytesLength) {
61+
Objects.requireNonNull(random);
62+
final byte[] idBytes = new byte[idBytesLength];
63+
final byte[] secretBytes = new byte[secretBytesLength];
64+
random.nextBytes(idBytes);
65+
random.nextBytes(secretBytes);
66+
return new ApiKey(idBytes, secretBytes);
67+
}
68+
69+
public static ApiKey parse(final String value) {
70+
return parse(value, DEFAULT_ENCODER, DEFAULT_DECODER);
71+
}
72+
73+
public static ApiKey parse(final String value, final Function<byte[], String> encoder,
74+
final Function<String, byte[]> decoder) {
75+
Assert.hasText(value, "API key must be provided");
76+
Objects.requireNonNull(encoder);
77+
Objects.requireNonNull(decoder);
78+
79+
final String[] parts = value.split("_", -1);
80+
Assert.isTrue(parts.length == 2, "API key has invalid format");
81+
82+
final String apiKeyId = parts[0];
83+
Assert.hasText(apiKeyId, "API key has invalid format");
84+
85+
final String apiKeySecret = parts[1];
86+
Assert.hasText(apiKeySecret, "API key has invalid format");
87+
88+
return new ApiKey(apiKeyId, decoder.apply(apiKeySecret), encoder);
89+
}
90+
91+
private final String id;
92+
93+
private final byte[] secret;
94+
95+
private final Function<byte[], String> encoder;
96+
97+
private ApiKey(final byte[] id, final byte[] secret) {
98+
this(DEFAULT_ENCODER.apply(id), secret, DEFAULT_ENCODER);
99+
}
100+
101+
private ApiKey(final String id, final byte[] secret, final Function<byte[], String> encoder) {
102+
Assert.hasText(id, "API key ID cannot be empty");
103+
Assert.isTrue(secret != null && secret.length > 0, "API key secret required");
104+
Objects.requireNonNull(encoder);
105+
this.id = id;
106+
this.secret = Arrays.copyOf(secret, secret.length);
107+
this.encoder = encoder;
108+
}
109+
110+
public String getId() {
111+
return this.id;
112+
}
113+
114+
public byte[] getSecret() {
115+
return Arrays.copyOf(this.secret, this.secret.length);
116+
}
117+
118+
public String asToken() {
119+
return this.id + '_' + this.encoder.apply(this.secret);
120+
}
121+
122+
@Override
123+
public String toString() {
124+
return "DefaultApiKey{id='" + this.id + '}';
125+
}
126+
127+
private static final Function<byte[], String> DEFAULT_ENCODER = Base64.getEncoder()
128+
.withoutPadding()::encodeToString;
129+
130+
private static final Function<String, byte[]> DEFAULT_DECODER = Base64.getDecoder()::decode;
131+
132+
}

0 commit comments

Comments
 (0)