Skip to content

Commit 4fe4af8

Browse files
rework an API key hashing and validation logic - make it more secure
1 parent 0b314f8 commit 4fe4af8

File tree

8 files changed

+323
-52
lines changed

8 files changed

+323
-52
lines changed

ApiKeyGenerator.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import javax.crypto.SecretKeyFactory;
2+
import javax.crypto.spec.PBEKeySpec;
3+
import java.security.SecureRandom;
4+
import java.util.Base64;
5+
6+
public class ApiKeyGenerator {
7+
8+
public static String generateApiKey() {
9+
byte[] randomBytes = new byte[32]; // 256 bits
10+
new SecureRandom().nextBytes(randomBytes);
11+
return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
12+
}
13+
14+
public static byte[] generateSalt() {
15+
byte[] salt = new byte[16]; // 128-bit salt
16+
new SecureRandom().nextBytes(salt);
17+
return salt;
18+
}
19+
20+
public static String hashApiKey(String apiKey, byte[] salt) throws Exception {
21+
PBEKeySpec spec = new PBEKeySpec(apiKey.toCharArray(), salt, 100_000, 256); // iterations, key length
22+
SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
23+
byte[] hash = skf.generateSecret(spec).getEncoded();
24+
return Base64.getEncoder().encodeToString(hash);
25+
}
26+
27+
public static void main(String[] args) throws Exception {
28+
String apiKey = generateApiKey();
29+
byte[] salt = generateSalt();
30+
String hashedKey = hashApiKey(apiKey, salt);
31+
32+
System.out.println("API Key: " + apiKey);
33+
System.out.println("Salt (Base64): " + Base64.getEncoder().encodeToString(salt));
34+
System.out.println("Hashed Key (Base64): " + hashedKey);
35+
}
36+
}

README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,15 @@ aws dynamodb create-table \
8181
```
8282

8383
For the sake of simplicity an API key is inserted manually into the DynamoDB.
84-
An API key must be Base64 encoded string.
84+
An API key must be CSPRNG 256-bit key while being PBKDF2 encoded prior to being stored in the database.
85+
86+
The util class is called 'ApiKeyGenerator.java' and is located in the root of the project.
87+
To get the key just run:
88+
89+
```shell
90+
java ApiKeyGenerator.java
91+
```
92+
8593
In real production-grade application the same approach could be followed as well, when more manual approach is
8694
acceptable.
8795
However, it also could be that an API key is generated by the 3rd party service as well.
@@ -98,10 +106,10 @@ Here is an example:
98106
aws dynamodb put-item \
99107
--table-name api_key \
100108
--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"}}'
109+
'{"apiKeyId": {"S": "c09e472f-08ad-42a8-8e72-22205bc4d262"}, "apiKey": {"S": "bFw/JX/Pb7Cg5h9f+2SzU2jq4i2UtaxExGZvBA3C1n4="}, "salt": {"S": "DSB/V/61ng9kxukgr1FEnQ=="}, "assignee": {"S": "Sergejs Visockis"}, "assigneeContactDetails": {"S": "[email protected]"}, "expirationDate": {"S": "2026-08-16T14:14:14.627646"}}'
102110
```
103111

104-
An API key is - 66a19031-f7f4-4ae6-94e7-89e9d79fd401
112+
An API key is - c09e472f-08ad-42a8-8e72-22205bc4d262.vQKdiCGDJFyhXZJsvZ_M_0FOOLgBN4L77vm-xDZdglE
105113

106114
Make an SNS topic:
107115

service/src/main/java/io/github/sergejsvisockis/documentservice/auth/AuthenticationService.java

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
import org.springframework.stereotype.Service;
1212

1313
import java.time.LocalDateTime;
14-
import java.util.Base64;
1514
import java.util.concurrent.TimeUnit;
1615

16+
import static io.github.sergejsvisockis.documentservice.utils.ApiKeyUtil.decodeSalt;
17+
import static io.github.sergejsvisockis.documentservice.utils.ApiKeyUtil.hashApiKey;
18+
1719
@Service
1820
public class AuthenticationService {
1921

@@ -40,39 +42,46 @@ public Authentication getAuthentication(HttpServletRequest request) {
4042
throw new BadCredentialsException(INCORRECT_API_KEY_MESSAGE);
4143
}
4244

43-
ApiKey apiAuthKey = findApiAuthKey(apiKey);
45+
String[] splitHeader = apiKey.split("\\.");
46+
String keyId = splitHeader[0];
47+
String headerApiKey = splitHeader[1];
4448

45-
if (!isValid(apiAuthKey)) {
46-
throw new BadCredentialsException(API_KEY_EXPIRED_MESSAGE);
47-
}
49+
ApiKey apiAuthKey = findApiAuthKey(keyId);
50+
isValid(apiAuthKey, headerApiKey);
4851

4952
return new ApiKeyAuthentication(apiKey, AuthorityUtils.NO_AUTHORITIES);
5053
}
5154

52-
private boolean isValid(ApiKey apiKey) {
55+
private void isValid(ApiKey apiKey, String headerApiKey) {
56+
byte[] decodedSalt = decodeSalt(apiKey.getSalt());
57+
58+
String hashedApiKey = hashApiKey(headerApiKey, decodedSalt);
59+
60+
if (!hashedApiKey.equals(apiKey.getApiKey())) {
61+
throw new BadCredentialsException(INCORRECT_API_KEY_MESSAGE);
62+
}
63+
5364
LocalDateTime validTo = LocalDateTime.parse(apiKey.getExpirationDate());
54-
return LocalDateTime.now().isBefore(validTo);
65+
if (LocalDateTime.now().isEqual(validTo)) {
66+
throw new BadCredentialsException(API_KEY_EXPIRED_MESSAGE);
67+
}
5568
}
5669

57-
private ApiKey findApiAuthKey(String key) {
58-
ApiKey fromCache = cache.getIfPresent(key);
70+
private ApiKey findApiAuthKey(String keyId) {
71+
ApiKey fromCache = cache.getIfPresent(keyId);
5972

6073
if (fromCache != null) {
6174
return fromCache;
6275
}
6376

64-
ApiKey apiKey = apiKeyRepository.findApiKey(encodeApiKey(key));
77+
ApiKey apiKey = apiKeyRepository.findApiKey(keyId);
6578

6679
if (apiKey == null) {
6780
throw new BadCredentialsException(NO_API_KEY_FOUND_MESSAGE);
6881
}
6982

70-
cache.put(key, apiKey);
83+
cache.put(keyId, apiKey);
7184
return apiKey;
7285
}
7386

74-
private String encodeApiKey(String key) {
75-
return Base64.getEncoder().encodeToString(key.getBytes());
76-
}
77-
7887
}

service/src/main/java/io/github/sergejsvisockis/documentservice/repository/ApiKey.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public class ApiKey {
1717

1818
private String apiKeyId;
1919
private String apiKey;
20+
private String salt;
2021
private String assignee;
2122
private String assigneeContactDetails;
2223
private String expirationDate;
@@ -31,6 +32,11 @@ public String getApiKey() {
3132
return apiKey;
3233
}
3334

35+
@DynamoDbAttribute("salt")
36+
public String getSalt() {
37+
return salt;
38+
}
39+
3440
@DynamoDbAttribute("assignee")
3541
public String getAssignee() {
3642
return assignee;

service/src/main/java/io/github/sergejsvisockis/documentservice/repository/ApiKeyRepository.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ public class ApiKeyRepository {
1515

1616
private final DynamoDbTemplate dynamoDbTemplate;
1717

18-
public ApiKey findApiKey(String key) {
18+
public ApiKey findApiKey(String keyId) {
1919
ScanEnhancedRequest request = ScanEnhancedRequest.builder()
2020
.filterExpression(Expression.builder()
21-
.expression("apiKey = :key")
22-
.expressionValues(Map.of(":key", AttributeValue.fromS(key)))
21+
.expression("apiKeyId = :keyId")
22+
.expressionValues(Map.of(":keyId", AttributeValue.fromS(keyId)))
2323
.build())
2424
.build();
2525
return dynamoDbTemplate.scan(request, ApiKey.class)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package io.github.sergejsvisockis.documentservice.utils;
2+
3+
import javax.crypto.SecretKeyFactory;
4+
import javax.crypto.spec.PBEKeySpec;
5+
import java.util.Arrays;
6+
import java.util.Base64;
7+
8+
public final class ApiKeyUtil {
9+
10+
private ApiKeyUtil() {
11+
}
12+
13+
private static final String ALGORITHM = "PBKDF2WithHmacSHA256";
14+
15+
public static String hashApiKey(String apiKey, byte[] salt) {
16+
try {
17+
PBEKeySpec spec = new PBEKeySpec(apiKey.toCharArray(), salt, 100_000, 256); // iterations, key length
18+
SecretKeyFactory skf = SecretKeyFactory.getInstance(ALGORITHM);
19+
byte[] hash = skf.generateSecret(spec).getEncoded();
20+
return Base64.getEncoder().encodeToString(hash);
21+
} catch (Exception e) {
22+
throw new IllegalStateException("Failed to hash an API key=" + apiKey
23+
+ "and salt=" + Arrays.toString(salt), e);
24+
}
25+
}
26+
27+
public static byte[] decodeSalt(String salt) {
28+
return Base64.getDecoder().decode(salt);
29+
}
30+
31+
}

service/src/test/java/io/github/sergejsvisockis/documentservice/auth/AuthenticationServiceTest.java

Lines changed: 92 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,47 @@
22

33
import io.github.sergejsvisockis.documentservice.repository.ApiKey;
44
import io.github.sergejsvisockis.documentservice.repository.ApiKeyRepository;
5+
import io.github.sergejsvisockis.documentservice.utils.ApiKeyUtil;
56
import jakarta.servlet.http.HttpServletRequest;
7+
import org.junit.jupiter.api.BeforeEach;
68
import org.junit.jupiter.api.Test;
79
import org.junit.jupiter.api.extension.ExtendWith;
810
import org.mockito.InjectMocks;
911
import org.mockito.Mock;
10-
import org.mockito.Mockito;
12+
import org.mockito.MockedStatic;
1113
import org.mockito.junit.jupiter.MockitoExtension;
1214
import org.springframework.security.authentication.BadCredentialsException;
1315
import org.springframework.security.core.Authentication;
1416

17+
import java.lang.reflect.InvocationTargetException;
18+
import java.lang.reflect.Method;
19+
1520
import java.time.LocalDateTime;
1621
import java.util.Base64;
1722

1823
import static io.github.sergejsvisockis.documentservice.auth.AuthenticationService.API_KEY_EXPIRED_MESSAGE;
24+
import static io.github.sergejsvisockis.documentservice.auth.AuthenticationService.AUTH_TOKEN_HEADER_NAME;
1925
import static io.github.sergejsvisockis.documentservice.auth.AuthenticationService.INCORRECT_API_KEY_MESSAGE;
2026
import static io.github.sergejsvisockis.documentservice.auth.AuthenticationService.NO_API_KEY_FOUND_MESSAGE;
2127
import static org.junit.jupiter.api.Assertions.assertEquals;
28+
import static org.junit.jupiter.api.Assertions.assertNotNull;
2229
import static org.junit.jupiter.api.Assertions.assertThrows;
30+
import static org.junit.jupiter.api.Assertions.assertTrue;
31+
import static org.junit.jupiter.api.Assertions.fail;
32+
import static org.mockito.Mockito.mockStatic;
33+
import static org.mockito.Mockito.times;
34+
import static org.mockito.Mockito.verify;
2335
import static org.mockito.Mockito.when;
2436

2537
@ExtendWith(MockitoExtension.class)
2638
class AuthenticationServiceTest {
2739

28-
private static final String AUTH_TOKEN_HEADER_NAME = "x-api-key";
29-
private static final String VALID_API_KEY = "valid-api-key";
30-
private static final String ENCODED_API_KEY = Base64.getEncoder().encodeToString(VALID_API_KEY.getBytes());
40+
private static final String KEY_ID = "test-key-id";
41+
private static final String API_KEY = "test-api-key";
42+
private static final String VALID_HEADER_API_KEY = KEY_ID + "." + API_KEY;
43+
private static final String HASHED_API_KEY = "hashedApiKey123";
44+
private static final String ENCODED_SALT = Base64.getEncoder().encodeToString("test-salt".getBytes());
45+
private static final byte[] DECODED_SALT = "test-salt".getBytes();
3146

3247
@Mock
3348
private ApiKeyRepository apiKeyRepository;
@@ -38,58 +53,104 @@ class AuthenticationServiceTest {
3853
@InjectMocks
3954
private AuthenticationService authenticationService;
4055

56+
private ApiKey validApiKey;
57+
58+
@BeforeEach
59+
void setUp() {
60+
validApiKey = new ApiKey();
61+
validApiKey.setApiKeyId(KEY_ID);
62+
validApiKey.setApiKey(HASHED_API_KEY);
63+
validApiKey.setSalt(ENCODED_SALT);
64+
validApiKey.setExpirationDate(LocalDateTime.now().plusDays(1).toString());
65+
}
66+
4167
@Test
4268
void shouldReturnAuthenticationWhenApiKeyIsValid() {
4369
// given
44-
ApiKey apiKey = new ApiKey();
45-
apiKey.setApiKey(ENCODED_API_KEY);
46-
apiKey.setExpirationDate(LocalDateTime.now().plusDays(1).toString());
47-
48-
when(request.getHeader(AUTH_TOKEN_HEADER_NAME)).thenReturn(VALID_API_KEY);
49-
when(apiKeyRepository.findApiKey(ENCODED_API_KEY)).thenReturn(apiKey);
50-
51-
// when
52-
Authentication authentication = authenticationService.getAuthentication(request);
53-
54-
// then
55-
assertEquals(VALID_API_KEY, authentication.getPrincipal());
70+
when(request.getHeader(AUTH_TOKEN_HEADER_NAME)).thenReturn(VALID_HEADER_API_KEY);
71+
when(apiKeyRepository.findApiKey(KEY_ID)).thenReturn(validApiKey);
72+
73+
try (MockedStatic<ApiKeyUtil> apiKeyUtilMock = mockStatic(ApiKeyUtil.class)) {
74+
apiKeyUtilMock.when(() -> ApiKeyUtil.decodeSalt(ENCODED_SALT)).thenReturn(DECODED_SALT);
75+
apiKeyUtilMock.when(() -> ApiKeyUtil.hashApiKey(API_KEY, DECODED_SALT)).thenReturn(HASHED_API_KEY);
76+
77+
// when
78+
Authentication authentication = authenticationService.getAuthentication(request);
79+
80+
// then
81+
assertNotNull(authentication);
82+
assertEquals(VALID_HEADER_API_KEY, authentication.getPrincipal());
83+
apiKeyUtilMock.verify(() -> ApiKeyUtil.decodeSalt(ENCODED_SALT));
84+
apiKeyUtilMock.verify(() -> ApiKeyUtil.hashApiKey(API_KEY, DECODED_SALT));
85+
}
5686
}
5787

5888
@Test
59-
void shouldThrowExceptionWhenApiKeyIsNotFound() {
89+
void shouldThrowExceptionWhenApiKeyHeaderIsMissing() {
6090
// given
61-
when(request.getHeader(AUTH_TOKEN_HEADER_NAME)).thenReturn(VALID_API_KEY);
62-
when(apiKeyRepository.findApiKey(ENCODED_API_KEY)).thenReturn(null);
91+
when(request.getHeader(AUTH_TOKEN_HEADER_NAME)).thenReturn(null);
6392

6493
// when & then
6594
BadCredentialsException exception = assertThrows(BadCredentialsException.class,
6695
() -> authenticationService.getAuthentication(request));
67-
assertEquals(NO_API_KEY_FOUND_MESSAGE, exception.getMessage());
96+
assertEquals(INCORRECT_API_KEY_MESSAGE, exception.getMessage());
6897
}
6998

7099
@Test
71-
void shouldThrowExceptionWhenApiKeyIsNull() {
100+
void shouldThrowExceptionWhenApiKeyFormatIsInvalid() {
72101
// given
73-
when(request.getHeader(AUTH_TOKEN_HEADER_NAME)).thenReturn(null);
102+
when(request.getHeader(AUTH_TOKEN_HEADER_NAME)).thenReturn("invalid-format");
74103

75104
// when & then
76-
BadCredentialsException exception = assertThrows(BadCredentialsException.class,
105+
assertThrows(ArrayIndexOutOfBoundsException.class,
77106
() -> authenticationService.getAuthentication(request));
78-
assertEquals(INCORRECT_API_KEY_MESSAGE, exception.getMessage());
79107
}
80108

81109
@Test
82-
void shouldThrowExceptionWhenApiKeyHasExpired() {
110+
void shouldThrowExceptionWhenApiKeyIsNotFound() {
83111
// given
84-
ApiKey apiKey = Mockito.mock(ApiKey.class);
85-
when(apiKey.getExpirationDate()).thenReturn(LocalDateTime.now().minusDays(1).toString());
86-
when(request.getHeader(AUTH_TOKEN_HEADER_NAME)).thenReturn(VALID_API_KEY);
87-
when(apiKeyRepository.findApiKey(ENCODED_API_KEY)).thenReturn(apiKey);
88-
112+
when(request.getHeader(AUTH_TOKEN_HEADER_NAME)).thenReturn(VALID_HEADER_API_KEY);
113+
when(apiKeyRepository.findApiKey(KEY_ID)).thenReturn(null);
89114

90115
// when & then
91116
BadCredentialsException exception = assertThrows(BadCredentialsException.class,
92117
() -> authenticationService.getAuthentication(request));
93-
assertEquals(API_KEY_EXPIRED_MESSAGE, exception.getMessage());
118+
assertEquals(NO_API_KEY_FOUND_MESSAGE, exception.getMessage());
119+
}
120+
121+
@Test
122+
void shouldThrowExceptionWhenApiKeyHashDoesNotMatch() {
123+
// given
124+
when(request.getHeader(AUTH_TOKEN_HEADER_NAME)).thenReturn(VALID_HEADER_API_KEY);
125+
when(apiKeyRepository.findApiKey(KEY_ID)).thenReturn(validApiKey);
126+
127+
try (MockedStatic<ApiKeyUtil> apiKeyUtilMock = mockStatic(ApiKeyUtil.class)) {
128+
apiKeyUtilMock.when(() -> ApiKeyUtil.decodeSalt(ENCODED_SALT)).thenReturn(DECODED_SALT);
129+
apiKeyUtilMock.when(() -> ApiKeyUtil.hashApiKey(API_KEY, DECODED_SALT)).thenReturn("different-hash");
130+
131+
// when & then
132+
BadCredentialsException exception = assertThrows(BadCredentialsException.class,
133+
() -> authenticationService.getAuthentication(request));
134+
assertEquals(INCORRECT_API_KEY_MESSAGE, exception.getMessage());
135+
}
136+
}
137+
138+
@Test
139+
void shouldUseCacheForRepeatedRequests() {
140+
// given
141+
when(request.getHeader(AUTH_TOKEN_HEADER_NAME)).thenReturn(VALID_HEADER_API_KEY);
142+
when(apiKeyRepository.findApiKey(KEY_ID)).thenReturn(validApiKey);
143+
144+
try (MockedStatic<ApiKeyUtil> apiKeyUtilMock = mockStatic(ApiKeyUtil.class)) {
145+
apiKeyUtilMock.when(() -> ApiKeyUtil.decodeSalt(ENCODED_SALT)).thenReturn(DECODED_SALT);
146+
apiKeyUtilMock.when(() -> ApiKeyUtil.hashApiKey(API_KEY, DECODED_SALT)).thenReturn(HASHED_API_KEY);
147+
148+
// when
149+
authenticationService.getAuthentication(request);
150+
authenticationService.getAuthentication(request);
151+
152+
// then
153+
verify(apiKeyRepository, times(1)).findApiKey(KEY_ID);
154+
}
94155
}
95156
}

0 commit comments

Comments
 (0)