Skip to content

Commit fbde5a2

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

File tree

16 files changed

+1175
-0
lines changed

16 files changed

+1175
-0
lines changed

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

Lines changed: 66 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;
@@ -1591,6 +1592,71 @@ public HttpSecurity oneTimeTokenLogin(
15911592
return HttpSecurity.this;
15921593
}
15931594

1595+
/**
1596+
* Configures API key authentication support.
1597+
*
1598+
* <h2>Example Configuration</h2>
1599+
*
1600+
* <pre>
1601+
* &#064;Configuration
1602+
* &#064;EnableWebSecurity
1603+
* public class SecurityConfig {
1604+
*
1605+
* &#064;Bean
1606+
* public ApiKeyDigest apiKeyDigest() {
1607+
* return new Sha3ApiKeyDigest();
1608+
* }
1609+
*
1610+
* &#064;Bean
1611+
* public ApiKeySearchService apiKeySearchService(JdbcTemplate jdbc) {
1612+
* return new ApiKeySearchServiceImpl(jdbc);
1613+
* }
1614+
*
1615+
* // separate filter chain for service-to-service requests
1616+
* &#064;Bean
1617+
* &#064;Order(1)
1618+
* public SecurityFilterChain apiKeySecurityFilterChain(
1619+
* HttpSecurity http,
1620+
* ApiKeyDigest digest,
1621+
* ApiKeySearchService searchService
1622+
* ) throws Exception {
1623+
* return http
1624+
* .securityMatcher("/s2s/do-something")
1625+
* .authorizeHttpRequests((authorize) -&gt; authorize
1626+
* .anyRequest().authenticated()
1627+
* )
1628+
* .apiKey(configurer -> configurer
1629+
* .digest(digest)
1630+
* .searchService(searchService())
1631+
* )
1632+
* // API key authentication is used for server-to-service interactions
1633+
* // which means there SHOULD be no browser, so no possibility for CSRF
1634+
* .csrf(AbstractHttpConfigurer::disable)
1635+
* .build();
1636+
* }
1637+
*
1638+
* // filter chain for user requests
1639+
* &#064;Bean
1640+
* &#064;Order(2)
1641+
* public SecurityFilterChain securityFilterChain(
1642+
* HttpSecurity http,
1643+
* ApiKeyDigest digest,
1644+
* ApiKeySearchService searchService
1645+
* ) throws Exception {
1646+
* // configure as usual
1647+
* }
1648+
*
1649+
* }
1650+
* </pre>
1651+
* @param configurerCustomizer the {@link Customizer} to provide more options for the {@link ApiKeyConfigurer}
1652+
* @return the {@link HttpSecurity} for further customizations
1653+
* @throws Exception
1654+
*/
1655+
public HttpSecurity apiKey( Customizer<ApiKeyConfigurer<HttpSecurity>> configurerCustomizer) throws Exception {
1656+
configurerCustomizer.customize(getOrApply(new ApiKeyConfigurer<>(getContext())));
1657+
return HttpSecurity.this;
1658+
}
1659+
15941660
/**
15951661
* Configures channel security. In order for this configuration to be useful at least
15961662
* one mapping to a required channel must be provided.
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package org.springframework.security.config.annotation.web.configurers;
2+
3+
import java.time.Clock;
4+
import java.util.Collection;
5+
import java.util.Objects;
6+
7+
import org.springframework.context.ApplicationContext;
8+
import org.springframework.core.convert.converter.Converter;
9+
import org.springframework.security.authentication.AuthenticationManager;
10+
import org.springframework.security.authentication.apikey.*;
11+
import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
12+
import org.springframework.security.core.GrantedAuthority;
13+
import org.springframework.security.core.context.SecurityContextHolderStrategy;
14+
import org.springframework.security.web.authentication.AuthenticationConverter;
15+
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
16+
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
17+
import org.springframework.security.web.authentication.apikey.ApiKeyAuthenticationFilter;
18+
import org.springframework.security.web.authentication.apikey.BearerTokenAuthenticationConverter;
19+
import org.springframework.security.web.context.NullSecurityContextRepository;
20+
import org.springframework.security.web.context.SecurityContextRepository;
21+
22+
/**
23+
* Configures API key authentication.
24+
*
25+
* @author Alexey Razinkov
26+
*/
27+
public final class ApiKeyConfigurer<H extends HttpSecurityBuilder<H>> extends AbstractHttpConfigurer<ApiKeyConfigurer<H>, H> {
28+
29+
private final ApplicationContext context;
30+
31+
private Clock clock = Clock.systemUTC();
32+
33+
private Converter<StoredApiKey, Collection<GrantedAuthority>> grantedAuthorityConverter =
34+
new ApiKeySimpleGrantedAuthorityConverter();
35+
36+
private ApiKeySearchService searchService;
37+
38+
private ApiKeyDigest digest;
39+
40+
private AuthenticationConverter authnConverter = new BearerTokenAuthenticationConverter();
41+
42+
private SecurityContextRepository securityContextRepository = new NullSecurityContextRepository();
43+
44+
private AuthenticationSuccessHandler successHandler;
45+
46+
private AuthenticationFailureHandler failureHandler;
47+
48+
public ApiKeyConfigurer(final ApplicationContext context) {
49+
this.context = Objects.requireNonNull(context);
50+
}
51+
52+
public ApiKeyConfigurer<H> clock(final Clock clock) {
53+
this.clock = Objects.requireNonNull(clock);
54+
return this;
55+
}
56+
57+
public ApiKeyConfigurer<H> grantedAuthorityConverter(
58+
final Converter<StoredApiKey, Collection<GrantedAuthority>> converter
59+
) {
60+
this.grantedAuthorityConverter = Objects.requireNonNull(converter);
61+
return this;
62+
}
63+
64+
public ApiKeyConfigurer<H> searchService(final ApiKeySearchService searchService) {
65+
this.searchService = Objects.requireNonNull(searchService);
66+
return this;
67+
}
68+
69+
public ApiKeyConfigurer<H> digest(final ApiKeyDigest digest) {
70+
this.digest = Objects.requireNonNull(digest);
71+
return this;
72+
}
73+
74+
public ApiKeyConfigurer<H> authenticationConverter(final AuthenticationConverter converter) {
75+
this.authnConverter = Objects.requireNonNull(converter);
76+
return this;
77+
}
78+
79+
public ApiKeyConfigurer<H> securityContextRepository(final SecurityContextRepository securityContextRepository) {
80+
this.securityContextRepository = Objects.requireNonNull(securityContextRepository);
81+
return this;
82+
}
83+
84+
public ApiKeyConfigurer<H> authenticationSuccessHandler(final AuthenticationSuccessHandler successHandler) {
85+
this.successHandler = successHandler;
86+
return this;
87+
}
88+
89+
public ApiKeyConfigurer<H> authenticationFailureHandler(final AuthenticationFailureHandler failureHandler) {
90+
this.failureHandler = failureHandler;
91+
return this;
92+
}
93+
94+
@Override
95+
public void init(final H builder) throws Exception {
96+
super.init(builder);
97+
final ApiKeySearchService searchService = getSearchService();
98+
final ApiKeyDigest digest = getDigest();
99+
final ApiKeyAuthenticationProvider authnProvider = new ApiKeyAuthenticationProvider(
100+
searchService,
101+
digest,
102+
this.clock,
103+
this.grantedAuthorityConverter
104+
);
105+
builder.authenticationProvider(authnProvider);
106+
}
107+
108+
private ApiKeySearchService getSearchService() {
109+
if (this.searchService != null) {
110+
return this.searchService;
111+
}
112+
113+
final ApiKeySearchService bean = this.context.getBean(ApiKeySearchService.class);
114+
if (bean == null) {
115+
throw new IllegalStateException("API key search service required");
116+
}
117+
118+
return bean;
119+
}
120+
121+
private ApiKeyDigest getDigest() {
122+
if (this.digest != null) {
123+
return this.digest;
124+
}
125+
126+
final ApiKeyDigest bean = this.context.getBean(ApiKeyDigest.class);
127+
if (bean == null) {
128+
throw new IllegalStateException("API key digest required");
129+
}
130+
131+
return bean;
132+
}
133+
134+
@Override
135+
public void configure(final H http) {
136+
final AuthenticationManager authnManager = http.getSharedObject(AuthenticationManager.class);
137+
final SecurityContextHolderStrategy securityContextHolderStrategy = getSecurityContextHolderStrategy();
138+
final ApiKeyAuthenticationFilter filter = new ApiKeyAuthenticationFilter(
139+
authnManager,
140+
this.authnConverter,
141+
securityContextHolderStrategy,
142+
this.securityContextRepository,
143+
this.successHandler,
144+
this.failureHandler
145+
);
146+
http.addFilter(postProcess(filter));
147+
}
148+
}
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)