Skip to content

Commit 5a68d42

Browse files
authored
Merge pull request #83 from it-at-m/v1.2
Added Source Address Whitelist and refactoring
2 parents 52cb475 + db6715b commit 5a68d42

File tree

12 files changed

+102
-33
lines changed

12 files changed

+102
-33
lines changed

captchaservice-backend/src/main/java/de/muenchen/captchaservice/configuration/captcha/CaptchaSite.java

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,23 @@ public record CaptchaSite(
1111
String secret,
1212
@Min(1) Integer maxVerifiesPerPayload,
1313
@NotNull List<DifficultyItem> difficultyMap,
14-
15-
@Min(0) @Max(32) Integer sourceAddressIpv4Cidr,
16-
@Min(0) @Max(128) Integer sourceAddressIpv6Cidr) {
14+
@Min(0) @Max(32) Integer sourceAddressIpv4NetSize,
15+
@Min(0) @Max(128) Integer sourceAddressIpv6NetSize,
16+
List<String> whitelistedSourceAddresses) {
1717
public CaptchaSite(
1818
final String siteKey,
1919
final String secret,
2020
final Integer maxVerifiesPerPayload,
2121
final List<DifficultyItem> difficultyMap,
22-
final Integer sourceAddressIpv4Cidr,
23-
final Integer sourceAddressIpv6Cidr) {
22+
final Integer sourceAddressIpv4NetSize,
23+
final Integer sourceAddressIpv6NetSize,
24+
final List<String> whitelistedSourceAddresses) {
2425
this.siteKey = siteKey;
2526
this.secret = secret;
2627
this.maxVerifiesPerPayload = maxVerifiesPerPayload != null ? maxVerifiesPerPayload : 1;
2728
this.difficultyMap = List.copyOf(difficultyMap);
28-
this.sourceAddressIpv4Cidr = sourceAddressIpv4Cidr != null ? sourceAddressIpv4Cidr : 32;
29-
this.sourceAddressIpv6Cidr = sourceAddressIpv6Cidr != null ? sourceAddressIpv6Cidr : 128;
29+
this.sourceAddressIpv4NetSize = sourceAddressIpv4NetSize != null ? sourceAddressIpv4NetSize : 32;
30+
this.sourceAddressIpv6NetSize = sourceAddressIpv6NetSize != null ? sourceAddressIpv6NetSize : 128;
31+
this.whitelistedSourceAddresses = whitelistedSourceAddresses != null ? List.copyOf(whitelistedSourceAddresses) : List.of();
3032
}
3133
}

captchaservice-backend/src/main/java/de/muenchen/captchaservice/data/SourceAddress.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package de.muenchen.captchaservice.data;
22

33
import lombok.AllArgsConstructor;
4+
import lombok.Data;
45
import org.apache.commons.codec.digest.DigestUtils;
56

67
@AllArgsConstructor
8+
@Data
79
public class SourceAddress {
810

911
final private String sourceAddress;

captchaservice-backend/src/main/java/de/muenchen/captchaservice/entity/CaptchaRequest.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@
88
import jakarta.validation.constraints.NotNull;
99
import jakarta.validation.constraints.Size;
1010
import lombok.*;
11+
import org.hibernate.annotations.CreationTimestamp;
1112

1213
import java.time.Instant;
1314

1415
@Entity
1516
@Table(
1617
indexes = {
1718
@Index(name = "idx_captcha_request_source_address_hash", columnList = "sourceAddressHash"),
18-
@Index(name = "idx_captcha_request_expires_at", columnList = "expiresAt")
19+
@Index(name = "idx_captcha_request_source_address_hash_expires_at", columnList = "sourceAddressHash, expiresAt")
1920
}
2021
)
2122

@@ -25,7 +26,6 @@
2526
@ToString(callSuper = true)
2627
@EqualsAndHashCode(callSuper = true)
2728
@NoArgsConstructor
28-
@AllArgsConstructor
2929
public class CaptchaRequest extends BaseEntity {
3030

3131
private static final long serialVersionUID = 1L;
@@ -34,12 +34,25 @@ public class CaptchaRequest extends BaseEntity {
3434
// Variables //
3535
// ========= //
3636

37+
@CreationTimestamp
38+
@Column(updatable = false)
39+
private Instant requestAt;
40+
3741
@Column(nullable = false, length = 64)
3842
@NotNull
3943
@Size(min = 64, max = 64)
4044
private String sourceAddressHash;
4145

46+
@NotNull
47+
private boolean isWhitelisted;
48+
4249
@NotNull
4350
private Instant expiresAt;
4451

52+
public CaptchaRequest(String sourceAddressHash, boolean isWhitelisted, Instant expiresAt) {
53+
this.sourceAddressHash = sourceAddressHash;
54+
this.isWhitelisted = isWhitelisted;
55+
this.expiresAt = expiresAt;
56+
}
57+
4558
}

captchaservice-backend/src/main/java/de/muenchen/captchaservice/entity/InvalidatedPayload.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
@Table(
1616
indexes = {
1717
@Index(name = "idx_invalidated_payload_payload_hash", columnList = "payloadHash"),
18-
@Index(name = "idx_invalidated_payload_expires_at", columnList = "expiresAt")
18+
@Index(name = "idx_invalidated_payload_payload_hash_expires_at", columnList = "payloadHash, expiresAt")
1919
}
2020
)
2121
// Definition of getter, setter, ...

captchaservice-backend/src/main/java/de/muenchen/captchaservice/service/captcha/CaptchaService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public CaptchaService(final CaptchaProperties captchaProperties, final Difficult
3232

3333
public Altcha.Challenge createChallenge(final String siteKey, final SourceAddress sourceAddress) {
3434
final long difficulty = difficultyService.getDifficultyForSourceAddress(siteKey, sourceAddress);
35-
difficultyService.registerRequest(sourceAddress);
35+
difficultyService.registerRequest(siteKey, sourceAddress);
3636
final Altcha.ChallengeOptions options = new Altcha.ChallengeOptions();
3737
options.algorithm = Altcha.Algorithm.SHA256;
3838
options.hmacKey = captchaProperties.hmacKey();

captchaservice-backend/src/main/java/de/muenchen/captchaservice/service/difficulty/DifficultyService.java

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@
88
import de.muenchen.captchaservice.repository.CaptchaRequestRepository;
99
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
1010
import lombok.extern.slf4j.Slf4j;
11+
import org.springframework.security.web.util.matcher.IpAddressMatcher;
1112
import org.springframework.stereotype.Service;
1213

1314
import java.time.Instant;
15+
import java.util.Map;
1416
import java.util.Optional;
17+
import java.util.concurrent.ConcurrentHashMap;
1518

1619
@Service
1720
@Slf4j
@@ -21,21 +24,26 @@ public class DifficultyService {
2124

2225
private final CaptchaRequestRepository captchaRequestRepository;
2326

27+
private final Map<String, IpAddressMatcher> matcherCache = new ConcurrentHashMap<>();
28+
2429
@SuppressFBWarnings(value = { "EI_EXPOSE_REP2" }, justification = "Dependency Injection")
2530
public DifficultyService(final CaptchaProperties captchaProperties, final CaptchaRequestRepository captchaRequestRepository) {
2631
this.captchaProperties = captchaProperties;
2732
this.captchaRequestRepository = captchaRequestRepository;
2833
}
2934

30-
public void registerRequest(final SourceAddress sourceAddress) {
35+
public void registerRequest(final String siteKey, final SourceAddress sourceAddress) {
3136
final String sourceAddressHash = sourceAddress.getHash();
32-
final CaptchaRequest captchaRequest = new CaptchaRequest(sourceAddressHash,
37+
final CaptchaRequest captchaRequest = new CaptchaRequest(sourceAddressHash, isSourceAddressWhitelisted(siteKey, sourceAddress),
3338
Instant.now().plusSeconds(captchaProperties.sourceAddressWindowSeconds()));
3439
captchaRequestRepository.save(captchaRequest);
3540
log.debug("Registered request for source address with hash {}", sourceAddressHash);
3641
}
3742

3843
public long getDifficultyForSourceAddress(final String siteKey, final SourceAddress sourceAddress) {
44+
if (isSourceAddressWhitelisted(siteKey, sourceAddress)) {
45+
return 1L;
46+
}
3947
final CaptchaSite captchaSite = captchaProperties.sites().get(siteKey);
4048
if (captchaSite == null) {
4149
throw new IllegalArgumentException("siteKey not found");
@@ -47,7 +55,7 @@ public long getDifficultyForSourceAddress(final String siteKey, final SourceAddr
4755
.difficultyMap()
4856
.stream()
4957
.sorted((o1, o2) -> Math.toIntExact(o2.minVisits() - o1.minVisits()))
50-
.filter(di -> di.minVisits() <= sourceVisitCount)
58+
.filter(di -> di.minVisits() - 1 <= sourceVisitCount)
5159
.findFirst();
5260
if (difficultyItem.isEmpty()) {
5361
log.error("No difficulty found site {} with {} visits", siteKey, sourceVisitCount);
@@ -58,4 +66,13 @@ public long getDifficultyForSourceAddress(final String siteKey, final SourceAddr
5866
return maxNumber;
5967
}
6068

69+
public boolean isSourceAddressWhitelisted(final String siteKey, final SourceAddress sourceAddress) {
70+
final CaptchaSite captchaSite = captchaProperties.sites().get(siteKey);
71+
for (String subnet : captchaSite.whitelistedSourceAddresses()) {
72+
IpAddressMatcher matcher = matcherCache.computeIfAbsent(subnet, IpAddressMatcher::new);
73+
if (matcher.matches(sourceAddress.getSourceAddress())) return true;
74+
}
75+
return false;
76+
}
77+
6178
}

captchaservice-backend/src/main/java/de/muenchen/captchaservice/service/sourceaddress/SourceAddressService.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ public SourceAddress parse(final String siteKey, final String sourceAddress) {
2020
String networkAddressString;
2121
InetAddress addr = InetAddresses.forString(sourceAddress);
2222
if (addr instanceof java.net.Inet4Address) {
23-
networkAddressString = NetworkAddressCalculator.getNetworkAddress(sourceAddress, site.sourceAddressIpv4Cidr());
23+
networkAddressString = NetworkAddressCalculator.getNetworkAddress(sourceAddress, site.sourceAddressIpv4NetSize());
2424
} else if (addr instanceof java.net.Inet6Address) {
25-
networkAddressString = NetworkAddressCalculator.getNetworkAddress(sourceAddress, site.sourceAddressIpv6Cidr());
25+
networkAddressString = NetworkAddressCalculator.getNetworkAddress(sourceAddress, site.sourceAddressIpv6NetSize());
2626
} else {
2727
throw new IllegalArgumentException("Unsupported IP address type: " + addr.getClass().getName());
2828
}

captchaservice-backend/src/main/resources/application-local.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,13 @@ security:
3939
captcha:
4040
hmac-key: secret
4141
sites:
42+
loadtest:
43+
secret: loadtest
44+
difficulty-map:
45+
- min-visits: 1
46+
max-number: 1
4247
test:
4348
secret: test
4449
difficulty-map:
45-
- min-visits: 0
50+
- min-visits: 1
4651
max-number: 1000

captchaservice-backend/src/main/resources/db/migration/schema/V001__Init.sql

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
CREATE TABLE captcha_request
22
(
33
id UUID NOT NULL,
4+
request_at TIMESTAMP WITH TIME ZONE,
45
source_address_hash VARCHAR(64) NOT NULL,
6+
is_whitelisted BOOLEAN NOT NULL,
57
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
68
CONSTRAINT pk_captcharequest PRIMARY KEY (id)
79
);
@@ -14,10 +16,10 @@ CREATE TABLE invalidated_payload
1416
CONSTRAINT pk_invalidatedpayload PRIMARY KEY (id)
1517
);
1618

17-
CREATE INDEX idx_captcha_request_expires_at ON captcha_request (expires_at);
18-
1919
CREATE INDEX idx_captcha_request_source_address_hash ON captcha_request (source_address_hash);
2020

21-
CREATE INDEX idx_invalidated_payload_expires_at ON invalidated_payload (expires_at);
21+
CREATE INDEX idx_captcha_request_source_address_hash_expires_at ON captcha_request (source_address_hash, expires_at);
22+
23+
CREATE INDEX idx_invalidated_payload_payload_hash ON invalidated_payload (payload_hash);
2224

23-
CREATE INDEX idx_invalidated_payload_payload_hash ON invalidated_payload (payload_hash);
25+
CREATE INDEX idx_invalidated_payload_payload_hash_expires_at ON invalidated_payload (payload_hash, expires_at);

captchaservice-backend/src/test/java/de/muenchen/captchaservice/service/difficulty/DifficultyServiceTest.java

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,23 +43,49 @@ void testDifficultyIncrease() {
4343
final SourceAddress sourceAddress = new SourceAddress("1.2.3.4");
4444
long difficulty;
4545
// --
46-
difficultyService.registerRequest(sourceAddress);
46+
difficultyService.registerRequest("test_site", sourceAddress);
4747
difficulty = difficultyService.getDifficultyForSourceAddress(TEST_SITE_KEY, sourceAddress);
4848
assertEquals(1_000L, difficulty);
4949
// --
50-
difficultyService.registerRequest(sourceAddress);
50+
difficultyService.registerRequest("test_site", sourceAddress);
5151
difficulty = difficultyService.getDifficultyForSourceAddress(TEST_SITE_KEY, sourceAddress);
5252
assertEquals(2_000L, difficulty);
5353
// --
54-
difficultyService.registerRequest(sourceAddress);
54+
difficultyService.registerRequest("test_site", sourceAddress);
5555
difficulty = difficultyService.getDifficultyForSourceAddress(TEST_SITE_KEY, sourceAddress);
5656
assertEquals(3_000L, difficulty);
5757
// --
5858
for (int i = 0; i < 5; i++) {
59-
difficultyService.registerRequest(sourceAddress);
59+
difficultyService.registerRequest("test_site", sourceAddress);
6060
}
6161
difficulty = difficultyService.getDifficultyForSourceAddress(TEST_SITE_KEY, sourceAddress);
6262
assertEquals(3_000L, difficulty);
6363
}
6464

65+
@Test
66+
@SneakyThrows
67+
void testDifficultyIncreaseWithWhitelistedSourceAddress() {
68+
databaseTestUtil.clearDatabase();
69+
final SourceAddress sourceAddress = new SourceAddress("10.1.2.3");
70+
long difficulty;
71+
// --
72+
difficultyService.registerRequest("test_site", sourceAddress);
73+
difficulty = difficultyService.getDifficultyForSourceAddress(TEST_SITE_KEY, sourceAddress);
74+
assertEquals(1L, difficulty);
75+
// --
76+
difficultyService.registerRequest("test_site", sourceAddress);
77+
difficulty = difficultyService.getDifficultyForSourceAddress(TEST_SITE_KEY, sourceAddress);
78+
assertEquals(1L, difficulty);
79+
// --
80+
difficultyService.registerRequest("test_site", sourceAddress);
81+
difficulty = difficultyService.getDifficultyForSourceAddress(TEST_SITE_KEY, sourceAddress);
82+
assertEquals(1L, difficulty);
83+
// --
84+
for (int i = 0; i < 5; i++) {
85+
difficultyService.registerRequest("test_site", sourceAddress);
86+
}
87+
difficulty = difficultyService.getDifficultyForSourceAddress(TEST_SITE_KEY, sourceAddress);
88+
assertEquals(1L, difficulty);
89+
}
90+
6591
}

0 commit comments

Comments
 (0)