Skip to content

Commit f669c1b

Browse files
add an API key authentication
1 parent 4c7f8f9 commit f669c1b

File tree

14 files changed

+619
-1
lines changed

14 files changed

+619
-1
lines changed

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,39 @@ aws dynamodb create-table \
7070
--provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5
7171
```
7272

73+
Make an API key DynamoDB table:
74+
75+
```shell
76+
aws dynamodb create-table \
77+
--table-name api_key \
78+
--attribute-definitions AttributeName=apiKeyId,AttributeType=S \
79+
--key-schema AttributeName=apiKeyId,KeyType=HASH \
80+
--provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5
81+
```
82+
83+
For the sake of simplicity an API key is inserted manually into the DynamoDB.
84+
An API key must be Base64 encoded string.
85+
In real production-grade application the same approach could be followed as well, when more manual approach is
86+
acceptable.
87+
However, it also could be that an API key is generated by the 3rd party service as well.
88+
89+
Overall API key suits the best for these types of applications due to the essential simplicity it has, including the
90+
fact that it works in isolation.
91+
Let's assume that this service is being built and supported by a separate team within the large enterprise.
92+
No OAuth2 is needed in that case since no support for a common SSO is required so as token exchange over the HTTP that
93+
comes with that.
94+
95+
Here is an example:
96+
97+
```shell
98+
aws dynamodb put-item \
99+
--table-name api_key \
100+
--item \
101+
'{"apiKeyId": {"S": "c09e472f-08ad-42a8-8e72-22205bc4d262"}, "apiKey": {"S": "NjZhMTkwMzEtZjdmNC00YWU2LTk0ZTctODllOWQ3OWZkNDAx"}, "assignee": {"S": "Sergejs Visockis"}, "assigneeContactDetails": {"S": "[email protected]"}, "expirationDate": {"S": "2026-08-16T14:14:14.627646"}}'
102+
```
103+
104+
An API key is - 66a19031-f7f4-4ae6-94e7-89e9d79fd401
105+
73106
Make an SNS topic:
74107

75108
```shell

service/pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
<groupId>org.springframework.boot</groupId>
2323
<artifactId>spring-boot-starter-web</artifactId>
2424
</dependency>
25+
<dependency>
26+
<groupId>org.springframework.boot</groupId>
27+
<artifactId>spring-boot-starter-security</artifactId>
28+
</dependency>
2529
<dependency>
2630
<groupId>org.projectlombok</groupId>
2731
<artifactId>lombok</artifactId>

service/src/main/java/io/github/sergejsvisockis/documentservice/Bootstrap.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
import org.springframework.boot.SpringApplication;
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
5+
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
56

6-
@SpringBootApplication
7+
@SpringBootApplication(exclude = {
8+
UserDetailsServiceAutoConfiguration.class
9+
})
710
public class Bootstrap {
811

912
public static void main(String[] args) {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package io.github.sergejsvisockis.documentservice.auth;
2+
3+
import org.springframework.security.authentication.AbstractAuthenticationToken;
4+
import org.springframework.security.core.GrantedAuthority;
5+
6+
import java.util.Collection;
7+
8+
public class ApiKeyAuthentication extends AbstractAuthenticationToken {
9+
10+
private final String apiKey;
11+
12+
public ApiKeyAuthentication(String apiKey, Collection<? extends GrantedAuthority> authorities) {
13+
super(authorities);
14+
this.apiKey = apiKey;
15+
setAuthenticated(true);
16+
}
17+
18+
@Override
19+
public Object getCredentials() {
20+
return null;
21+
}
22+
23+
@Override
24+
public Object getPrincipal() {
25+
return apiKey;
26+
}
27+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package io.github.sergejsvisockis.documentservice.auth;
2+
3+
import io.github.sergejsvisockis.documentservice.utils.JsonUtil;
4+
import jakarta.servlet.FilterChain;
5+
import jakarta.servlet.ServletRequest;
6+
import jakarta.servlet.ServletResponse;
7+
import jakarta.servlet.http.HttpServletRequest;
8+
import jakarta.servlet.http.HttpServletResponse;
9+
import lombok.RequiredArgsConstructor;
10+
import lombok.extern.slf4j.Slf4j;
11+
import org.springframework.http.MediaType;
12+
import org.springframework.security.core.Authentication;
13+
import org.springframework.security.core.context.SecurityContextHolder;
14+
import org.springframework.stereotype.Component;
15+
import org.springframework.web.filter.GenericFilterBean;
16+
17+
import java.io.IOException;
18+
import java.io.PrintWriter;
19+
20+
@Slf4j
21+
@RequiredArgsConstructor
22+
@Component
23+
public class AuthenticationFilter extends GenericFilterBean {
24+
25+
private final AuthenticationService authenticationService;
26+
27+
@Override
28+
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException {
29+
try {
30+
Authentication authentication = authenticationService.getAuthentication((HttpServletRequest) request);
31+
SecurityContextHolder.getContext().setAuthentication(authentication);
32+
filterChain.doFilter(request, response);
33+
} catch (Exception e) {
34+
HttpServletResponse httpResponse = (HttpServletResponse) response;
35+
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
36+
httpResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
37+
38+
PrintWriter writer = httpResponse.getWriter();
39+
writer.print(JsonUtil.toJson(new FailedAuthResponse(
40+
System.currentTimeMillis(),
41+
e.getMessage()))
42+
);
43+
writer.flush();
44+
writer.close();
45+
}
46+
}
47+
48+
record FailedAuthResponse(long timestamp, String message) {
49+
50+
}
51+
52+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package io.github.sergejsvisockis.documentservice.auth;
2+
3+
import io.github.sergejsvisockis.documentservice.repository.ApiKey;
4+
import io.github.sergejsvisockis.documentservice.repository.ApiKeyRepository;
5+
import jakarta.servlet.http.HttpServletRequest;
6+
import lombok.RequiredArgsConstructor;
7+
import org.springframework.security.authentication.BadCredentialsException;
8+
import org.springframework.security.core.Authentication;
9+
import org.springframework.security.core.authority.AuthorityUtils;
10+
import org.springframework.stereotype.Service;
11+
12+
import java.time.LocalDateTime;
13+
import java.util.Base64;
14+
import java.util.Optional;
15+
16+
@Service
17+
@RequiredArgsConstructor
18+
public class AuthenticationService {
19+
20+
static final String AUTH_TOKEN_HEADER_NAME = "x-api-key";
21+
static final String INCORRECT_API_KEY_MESSAGE = "Incorrect API key passed";
22+
static final String NO_API_KEY_FOUND_MESSAGE = "NO associated API key found";
23+
static final String API_KEY_EXPIRED_MESSAGE = "An API key has expired";
24+
25+
private final ApiKeyRepository apiKeyRepository;
26+
27+
public Authentication getAuthentication(HttpServletRequest request) {
28+
String apiKey = request.getHeader(AUTH_TOKEN_HEADER_NAME);
29+
if (apiKey == null) {
30+
throw new BadCredentialsException(INCORRECT_API_KEY_MESSAGE);
31+
}
32+
33+
Optional<ApiKey> apiAuthKey = findApiAuthKey(apiKey);
34+
if (apiAuthKey.isEmpty()) {
35+
throw new BadCredentialsException(NO_API_KEY_FOUND_MESSAGE);
36+
}
37+
38+
if (!isValid(apiAuthKey.get())) {
39+
throw new BadCredentialsException(API_KEY_EXPIRED_MESSAGE);
40+
}
41+
42+
return new ApiKeyAuthentication(apiKey, AuthorityUtils.NO_AUTHORITIES);
43+
}
44+
45+
private boolean isValid(ApiKey apiKey) {
46+
LocalDateTime validTo = LocalDateTime.parse(apiKey.getExpirationDate());
47+
return LocalDateTime.now().isBefore(validTo);
48+
}
49+
50+
private Optional<ApiKey> findApiAuthKey(String key) {
51+
return apiKeyRepository.findApiKey(encodeApiKey(key));
52+
53+
}
54+
55+
private String encodeApiKey(String key) {
56+
return Base64.getEncoder().encodeToString(key.getBytes());
57+
}
58+
59+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package io.github.sergejsvisockis.documentservice.config;
2+
3+
import io.github.sergejsvisockis.documentservice.auth.AuthenticationFilter;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.context.annotation.Bean;
6+
import org.springframework.context.annotation.Configuration;
7+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
8+
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
9+
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
10+
import org.springframework.security.config.http.SessionCreationPolicy;
11+
import org.springframework.security.web.SecurityFilterChain;
12+
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
13+
14+
@Configuration
15+
@EnableWebSecurity
16+
@RequiredArgsConstructor
17+
public class SecurityConfig {
18+
19+
private final AuthenticationFilter authenticationFilter;
20+
21+
@Bean
22+
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
23+
return http
24+
.csrf(AbstractHttpConfigurer::disable)
25+
.authorizeHttpRequests(registry ->
26+
registry.requestMatchers("/**").authenticated())
27+
.formLogin(AbstractHttpConfigurer::disable)
28+
.httpBasic(AbstractHttpConfigurer::disable)
29+
.sessionManagement(configurer -> configurer
30+
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
31+
.addFilterBefore(authenticationFilter, UsernamePasswordAuthenticationFilter.class)
32+
.build();
33+
}
34+
35+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package io.github.sergejsvisockis.documentservice.repository;
2+
3+
import io.github.sergejsvisockis.documentservice.dynamodb.TableName;
4+
import lombok.Setter;
5+
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbAttribute;
6+
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean;
7+
import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey;
8+
9+
import static io.github.sergejsvisockis.documentservice.repository.ApiKey.TABLE_NAME;
10+
11+
@Setter
12+
@DynamoDbBean
13+
@TableName(name = TABLE_NAME)
14+
public class ApiKey {
15+
16+
public static final String TABLE_NAME = "api_key";
17+
18+
private String apiKeyId;
19+
private String apiKey;
20+
private String assignee;
21+
private String assigneeContactDetails;
22+
private String expirationDate;
23+
24+
@DynamoDbPartitionKey
25+
public String getApiKeyId() {
26+
return apiKeyId;
27+
}
28+
29+
@DynamoDbAttribute("apiKey")
30+
public String getApiKey() {
31+
return apiKey;
32+
}
33+
34+
@DynamoDbAttribute("assignee")
35+
public String getAssignee() {
36+
return assignee;
37+
}
38+
39+
@DynamoDbAttribute("assigneeContactDetails")
40+
public String getAssigneeContactDetails() {
41+
return assigneeContactDetails;
42+
}
43+
44+
@DynamoDbAttribute("expirationDate")
45+
public String getExpirationDate() {
46+
return expirationDate;
47+
}
48+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package io.github.sergejsvisockis.documentservice.repository;
2+
3+
import io.awspring.cloud.dynamodb.DynamoDbTemplate;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.stereotype.Repository;
6+
import software.amazon.awssdk.enhanced.dynamodb.Expression;
7+
import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest;
8+
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
9+
10+
import java.util.Map;
11+
import java.util.Optional;
12+
13+
@Repository
14+
@RequiredArgsConstructor
15+
public class ApiKeyRepository {
16+
17+
private final DynamoDbTemplate dynamoDbTemplate;
18+
19+
public Optional<ApiKey> findApiKey(String key) {
20+
ScanEnhancedRequest request = ScanEnhancedRequest.builder()
21+
.filterExpression(Expression.builder()
22+
.expression("apiKey = :key")
23+
.expressionValues(Map.of(":key", AttributeValue.fromS(key)))
24+
.build())
25+
.build();
26+
return dynamoDbTemplate.scan(request, ApiKey.class)
27+
.stream()
28+
.flatMap(p -> p.items().stream())
29+
.findFirst();
30+
}
31+
32+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package io.github.sergejsvisockis.documentservice.auth;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.springframework.security.core.authority.AuthorityUtils;
5+
6+
import static org.junit.jupiter.api.Assertions.assertEquals;
7+
import static org.junit.jupiter.api.Assertions.assertNull;
8+
import static org.junit.jupiter.api.Assertions.assertTrue;
9+
10+
class ApiKeyAuthenticationTest {
11+
12+
@Test
13+
void shouldCreateAuthenticationWithApiKey() {
14+
// given
15+
String apiKey = "test-api-key";
16+
17+
// when
18+
ApiKeyAuthentication authentication = new ApiKeyAuthentication(apiKey, AuthorityUtils.NO_AUTHORITIES);
19+
20+
// then
21+
assertEquals(apiKey, authentication.getPrincipal());
22+
assertNull(authentication.getCredentials());
23+
assertTrue(authentication.isAuthenticated());
24+
}
25+
}

0 commit comments

Comments
 (0)