From 398bd17575869714fbe983fb2cad1df186808b98 Mon Sep 17 00:00:00 2001 From: whereareiam Date: Sat, 23 May 2026 15:32:27 +0900 Subject: [PATCH] refactor(recognition): extract recognition eligibility rule chain --- .../content/configuration/providers/index.mdx | 33 ++-- docs/content/configuration/settings/index.mdx | 35 +++-- .../RecognitionEligibilityRegistry.java | 40 +++++ .../RecognitionEligibilityRule.java | 63 ++++++++ .../RecognitionEligibilityService.java | 35 +++++ .../UntrustedIpRecognitionDecision.java | 37 ----- .../policy/UntrustedIpRecognitionPolicy.java | 32 ---- .../identica/model/config/Providers.java | 14 +- .../identica/model/config/Settings.java | 32 ++-- .../RecognitionEligibilityContext.java | 29 ++++ .../RecognitionEligibilityDecision.java | 18 +++ .../RecognitionEligibilityRuleDecision.java | 58 +++++++ .../recognition/RecognitionAttemptKind.java | 9 ++ .../{ => recognition}/RecognitionSignal.java | 2 +- .../recognition/RecognitionTrigger.java | 9 ++ .../identica/common/CommonConfiguration.java | 24 ++- .../config/defaults/SettingsDefaults.java | 9 +- .../DefaultSessionRecognitionService.java | 18 ++- ...DefaultRecognitionEligibilityRegistry.java | 42 +++++ .../DefaultRecognitionEligibilityService.java | 62 ++++++++ .../matcher/IpMatcher.java} | 4 +- .../eligibility/matcher/type/CidrMatcher.java | 30 ++++ .../matcher/type/ExactIpMatcher.java | 17 +++ ...itSelectionRecognitionEligibilityRule.java | 44 ++++++ .../RecognitionEnabledEligibilityRule.java | 66 ++++++++ ...ntrustedIpRecognitionEligibilityRule.java} | 81 +++++----- .../policy/matcher/type/CidrMatcher.java | 40 ----- .../policy/matcher/type/ExactIpMatcher.java | 21 --- .../defaults/ProvidersDefaultsTest.java | 4 +- .../config/defaults/SettingsDefaultsTest.java | 3 +- .../DefaultSessionRecognitionServiceTest.java | 23 ++- ...ultRecognitionEligibilityRegistryTest.java | 55 +++++++ ...aultRecognitionEligibilityServiceTest.java | 104 +++++++++++++ ...lectionRecognitionEligibilityRuleTest.java | 45 ++++++ ...RecognitionEnabledEligibilityRuleTest.java | 76 +++++++++ ...ustedIpRecognitionEligibilityRuleTest.java | 92 +++++++++++ .../UntrustedIpRecognitionGuardTest.java | 144 ------------------ .../phase/EvaluateHandshakePhase.java | 51 +++++-- .../engine/prepare/PreparePipelineTest.java | 43 +++--- 39 files changed, 1119 insertions(+), 425 deletions(-) create mode 100644 identica-api/src/main/java/me/whereareiam/identica/identity/session/recognition/eligibility/RecognitionEligibilityRegistry.java create mode 100644 identica-api/src/main/java/me/whereareiam/identica/identity/session/recognition/eligibility/RecognitionEligibilityRule.java create mode 100644 identica-api/src/main/java/me/whereareiam/identica/identity/session/recognition/eligibility/RecognitionEligibilityService.java delete mode 100644 identica-api/src/main/java/me/whereareiam/identica/identity/session/recognition/policy/UntrustedIpRecognitionDecision.java delete mode 100644 identica-api/src/main/java/me/whereareiam/identica/identity/session/recognition/policy/UntrustedIpRecognitionPolicy.java create mode 100644 identica-api/src/main/java/me/whereareiam/identica/model/session/recognition/eligibility/RecognitionEligibilityContext.java create mode 100644 identica-api/src/main/java/me/whereareiam/identica/model/session/recognition/eligibility/RecognitionEligibilityDecision.java create mode 100644 identica-api/src/main/java/me/whereareiam/identica/model/session/recognition/eligibility/RecognitionEligibilityRuleDecision.java create mode 100644 identica-api/src/main/java/me/whereareiam/identica/type/session/recognition/RecognitionAttemptKind.java rename identica-api/src/main/java/me/whereareiam/identica/type/session/{ => recognition}/RecognitionSignal.java (71%) create mode 100644 identica-api/src/main/java/me/whereareiam/identica/type/session/recognition/RecognitionTrigger.java create mode 100644 identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/DefaultRecognitionEligibilityRegistry.java create mode 100644 identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/DefaultRecognitionEligibilityService.java rename identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/{policy/matcher/UntrustedIpMatcher.java => eligibility/matcher/IpMatcher.java} (76%) create mode 100644 identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/matcher/type/CidrMatcher.java create mode 100644 identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/matcher/type/ExactIpMatcher.java create mode 100644 identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/ExplicitSelectionRecognitionEligibilityRule.java create mode 100644 identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/RecognitionEnabledEligibilityRule.java rename identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/{policy/UntrustedIpRecognitionGuard.java => eligibility/rule/UntrustedIpRecognitionEligibilityRule.java} (61%) delete mode 100644 identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/policy/matcher/type/CidrMatcher.java delete mode 100644 identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/policy/matcher/type/ExactIpMatcher.java create mode 100644 identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/DefaultRecognitionEligibilityRegistryTest.java create mode 100644 identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/DefaultRecognitionEligibilityServiceTest.java create mode 100644 identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/ExplicitSelectionRecognitionEligibilityRuleTest.java create mode 100644 identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/RecognitionEnabledEligibilityRuleTest.java create mode 100644 identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/UntrustedIpRecognitionEligibilityRuleTest.java delete mode 100644 identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/policy/UntrustedIpRecognitionGuardTest.java diff --git a/docs/content/configuration/providers/index.mdx b/docs/content/configuration/providers/index.mdx index 690c155a1..db063a9ed 100644 --- a/docs/content/configuration/providers/index.mdx +++ b/docs/content/configuration/providers/index.mdx @@ -22,13 +22,14 @@ providers: enabled: true priority: 100 overrides: - allowRecognitionOnUntrustedIp: false sessionConcurrencyPolicy: "REJECT_NEW" recognition: enabled: true signals: - "USERNAME" - "VIRTUAL_HOST" + eligibility: + allowOnUntrustedIp: false ``` @@ -41,11 +42,13 @@ providers: "enabled": true, "priority": 100, "overrides": { - "allowRecognitionOnUntrustedIp": false, "sessionConcurrencyPolicy": "REJECT_NEW", "recognition": { "enabled": true, - "signals": ["USERNAME", "VIRTUAL_HOST"] + "signals": ["USERNAME", "VIRTUAL_HOST"], + "eligibility": { + "allowOnUntrustedIp": false + } } } } @@ -74,19 +77,6 @@ Configura keeps the object structure without filling every possible child field. If this is absent, Identica uses `settings.connection.sessions.concurrencyPolicy`. -#### overrides.allowRecognitionOnUntrustedIp - -Advanced - -- Takes: `true` or `false` -- Current default: `false` -- Affects: whether this provider may use recognition-based authentication bypass from IPs blocked by `settings.connection.sessions.recognition.untrustedIps` - -Leave this `false` for the normal safety posture. -Set it to `true` only when a specific provider may skip authentication from client IPs that are blocked for recognition by default. - -This override applies only to that provider. - #### overrides.recognition.enabled Warning @@ -108,6 +98,17 @@ Use it when one provider should allow reconnect recognition while another should When this field is absent or empty, the provider inherits `settings.connection.sessions.recognition.defaultSignals`. +#### overrides.recognition.eligibility.allowOnUntrustedIp + +Advanced + +- Takes: `true` or `false` +- Current default: `false` +- Affects: whether this provider stays recognition-eligible from client IPs blocked by `settings.connection.sessions.recognition.eligibility.untrustedIps` + +Leave this `false` for the normal safety posture. +Set it to `true` only when this specific provider should remain recognition-eligible on client IPs that are otherwise blocked. + Recognition is intentionally disabled by default because signal matching can be unsafe in shared-network environments. IP-based matching in particular can be exploited behind NAT, carrier-grade NAT, shared Wi-Fi, VPN exits, hosting proxies, or other setups where several different players appear to come from the same address. diff --git a/docs/content/configuration/settings/index.mdx b/docs/content/configuration/settings/index.mdx index 5b1fb5a50..9a6bf9d97 100644 --- a/docs/content/configuration/settings/index.mdx +++ b/docs/content/configuration/settings/index.mdx @@ -541,26 +541,26 @@ That includes NAT, carrier-grade NAT, shared Wi-Fi, VPN exits, reverse proxies, Enable recognition only if you understand that tradeoff and accept the risk for your environment. -#### recognition.untrustedIps.enabled +#### recognition.eligibility.untrustedIps.enabled Warning - Takes: `true` or `false` - Current default: `true` -- Affects: whether recognition-based authentication bypass is allowed for configured client IPs and CIDR ranges +- Affects: whether recognition remains eligible for configured client IPs and CIDR ranges -This guard controls recognition paths that can skip provider authentication, including reconnect recognition and provider-scoped handshake or session recognition. +This rule controls whether recognition may run at all, including reconnect recognition and provider-scoped handshake recognition. Use this when reconnect recognition is generally acceptable, but there are client networks you do not trust for automatic recognition decisions. The generated defaults target the common “proxy forwarded a loopback/private proxy IP instead of the real client IP” failure mode. -#### recognition.untrustedIps.entries +#### recognition.eligibility.untrustedIps.entries Warning - Takes: a list of exact IP literals or IPv4/IPv6 CIDR ranges - Current default: `["127.0.0.1", "::1", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]` -- Affects: which client IPs disable recognition-based authentication bypass +- Affects: which client IPs make recognition ineligible The generated defaults cover loopback and RFC1918 private ranges, which are often a sign that Identica is behind a proxy that is forwarding the wrong source IP. @@ -605,14 +605,15 @@ connection: - "USERNAME" - "IP" - "VIRTUAL_HOST" - untrustedIps: - enabled: true - entries: - - "127.0.0.1" - - "::1" - - "10.0.0.0/8" - - "172.16.0.0/12" - - "192.168.0.0/16" + eligibility: + untrustedIps: + enabled: true + entries: + - "127.0.0.1" + - "::1" + - "10.0.0.0/8" + - "172.16.0.0/12" + - "192.168.0.0/16" ``` @@ -626,9 +627,11 @@ connection: "enabled": false, "validity": "12h", "defaultSignals": ["USERNAME", "IP", "VIRTUAL_HOST"], - "untrustedIps": { - "enabled": true, - "entries": ["127.0.0.1", "::1", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"] + "eligibility": { + "untrustedIps": { + "enabled": true, + "entries": ["127.0.0.1", "::1", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"] + } } } } diff --git a/identica-api/src/main/java/me/whereareiam/identica/identity/session/recognition/eligibility/RecognitionEligibilityRegistry.java b/identica-api/src/main/java/me/whereareiam/identica/identity/session/recognition/eligibility/RecognitionEligibilityRegistry.java new file mode 100644 index 000000000..f4064fde7 --- /dev/null +++ b/identica-api/src/main/java/me/whereareiam/identica/identity/session/recognition/eligibility/RecognitionEligibilityRegistry.java @@ -0,0 +1,40 @@ +package me.whereareiam.identica.identity.session.recognition.eligibility; + +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * Runtime registry of recognition eligibility rules. + * + *

Addons may register additional rules after Identica is initialized.

+ * + *
{@code
+ * RecognitionEligibilityRegistry registry =
+ *         IdenticaAPI.getService(RecognitionEligibilityRegistry.class);
+ * registry.register(new CorporateRule());
+ * }
+ */ +public interface RecognitionEligibilityRegistry { + /** + * Registers or replaces a rule by id. + * + * @param rule rule to register + */ + void register(@NotNull RecognitionEligibilityRule rule); + + /** + * Unregisters a rule by id. + * + * @param ruleId rule id + * @return {@code true} when a rule was removed + */ + boolean unregister(@NotNull String ruleId); + + /** + * Resolves the currently registered rules. + * + * @return immutable list of registered rules + */ + @NotNull List resolve(); +} diff --git a/identica-api/src/main/java/me/whereareiam/identica/identity/session/recognition/eligibility/RecognitionEligibilityRule.java b/identica-api/src/main/java/me/whereareiam/identica/identity/session/recognition/eligibility/RecognitionEligibilityRule.java new file mode 100644 index 000000000..b1dd7adda --- /dev/null +++ b/identica-api/src/main/java/me/whereareiam/identica/identity/session/recognition/eligibility/RecognitionEligibilityRule.java @@ -0,0 +1,63 @@ +package me.whereareiam.identica.identity.session.recognition.eligibility; + +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityContext; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityRuleDecision; +import org.jetbrains.annotations.NotNull; + +/** + * Rule that contributes to recognition eligibility evaluation. + * + *

Rules run in ascending {@link #order()} and may abstain, allow, or block + * recognition for the current context.

+ * + *
{@code
+ * public final class CorporateRule implements RecognitionEligibilityRule {
+ *     public @NotNull String id() {
+ *         return "corporate";
+ *     }
+ *
+ *     public int order() {
+ *         return 50;
+ *     }
+ *
+ *     public @NotNull RecognitionEligibilityRuleDecision evaluate(
+ *             @NotNull RecognitionEligibilityContext context
+ *     ) {
+ *         return RecognitionEligibilityRuleDecision.abstain();
+ *     }
+ * }
+ * }
+ */ +public interface RecognitionEligibilityRule { + /** + * Stable rule identifier used for registration and replacement. + * + * @return stable rule id + */ + @NotNull String id(); + + /** + * Evaluation order. Lower values run first. + * + * @return rule order + */ + int order(); + + /** + * Optional guard for context-specific rules. + * + * @param context recognition attempt context + * @return {@code true} when this rule should evaluate + */ + default boolean supports(@NotNull RecognitionEligibilityContext context) { + return true; + } + + /** + * Evaluates this rule for the given context. + * + * @param context recognition attempt context + * @return per-rule decision + */ + @NotNull RecognitionEligibilityRuleDecision evaluate(@NotNull RecognitionEligibilityContext context); +} diff --git a/identica-api/src/main/java/me/whereareiam/identica/identity/session/recognition/eligibility/RecognitionEligibilityService.java b/identica-api/src/main/java/me/whereareiam/identica/identity/session/recognition/eligibility/RecognitionEligibilityService.java new file mode 100644 index 000000000..72c4cfc47 --- /dev/null +++ b/identica-api/src/main/java/me/whereareiam/identica/identity/session/recognition/eligibility/RecognitionEligibilityService.java @@ -0,0 +1,35 @@ +package me.whereareiam.identica.identity.session.recognition.eligibility; + +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityContext; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityDecision; +import org.jetbrains.annotations.NotNull; + +/** + * Resolves whether recognition may be attempted for a connection context. + * + *

This check runs before any stored recognition snapshot is matched. + * It allows core and addon rules to block or restore recognition eligibility + * for reconnect and provider-scoped handshake recognition paths.

+ * + *
{@code
+ * RecognitionEligibilityDecision decision = service.evaluate(
+ *         RecognitionEligibilityContext.builder()
+ *                 .providerId("premium")
+ *                 .attemptKind(RecognitionAttemptKind.SESSION_RECOGNITION)
+ *                 .trigger(RecognitionTrigger.AUTOMATIC)
+ *                 .build()
+ * );
+ * if (!decision.isAllowed()) {
+ *     return false;
+ * }
+ * }
+ */ +public interface RecognitionEligibilityService { + /** + * Evaluates whether recognition may proceed for the given context. + * + * @param context recognition attempt context + * @return resolved eligibility decision + */ + @NotNull RecognitionEligibilityDecision evaluate(@NotNull RecognitionEligibilityContext context); +} diff --git a/identica-api/src/main/java/me/whereareiam/identica/identity/session/recognition/policy/UntrustedIpRecognitionDecision.java b/identica-api/src/main/java/me/whereareiam/identica/identity/session/recognition/policy/UntrustedIpRecognitionDecision.java deleted file mode 100644 index e44762c85..000000000 --- a/identica-api/src/main/java/me/whereareiam/identica/identity/session/recognition/policy/UntrustedIpRecognitionDecision.java +++ /dev/null @@ -1,37 +0,0 @@ -package me.whereareiam.identica.identity.session.recognition.policy; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.ToString; -import org.jetbrains.annotations.NotNull; - -/** - * Result of evaluating whether automatic reconnect recognition may proceed for a connection. - */ -@Getter -@ToString -@AllArgsConstructor -public class UntrustedIpRecognitionDecision { - private final @NotNull Outcome outcome; - - /** - * Returns whether automatic reconnect recognition must be blocked. - * - * @return {@code true} when automatic recognition should be suppressed - */ - public boolean isBlocked() { - return outcome == Outcome.BLOCKED_UNTRUSTED_IP; - } - - /** - * Outcome of untrusted-IP recognition evaluation. - */ - public enum Outcome { - ALLOWED_DISABLED, - ALLOWED_MISSING_IP, - ALLOWED_IP_NOT_MATCHED, - ALLOWED_PROVIDER_OVERRIDE, - ALLOWED_EXPLICIT_SELECTION, - BLOCKED_UNTRUSTED_IP - } -} diff --git a/identica-api/src/main/java/me/whereareiam/identica/identity/session/recognition/policy/UntrustedIpRecognitionPolicy.java b/identica-api/src/main/java/me/whereareiam/identica/identity/session/recognition/policy/UntrustedIpRecognitionPolicy.java deleted file mode 100644 index 579818d69..000000000 --- a/identica-api/src/main/java/me/whereareiam/identica/identity/session/recognition/policy/UntrustedIpRecognitionPolicy.java +++ /dev/null @@ -1,32 +0,0 @@ -package me.whereareiam.identica.identity.session.recognition.policy; - -import me.whereareiam.identica.model.provider.ProviderContext; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -/** - * Evaluates whether automatic reconnect recognition should be suppressed for a connection. - */ -public interface UntrustedIpRecognitionPolicy { - /** - * Evaluates the automatic reconnect-recognition decision for the given provider and connection. - * - * @param providerId provider being auto-considered - * @param clientIp current client IP - * @param selectedProvider current scenario provider context - * @return resolved recognition decision - */ - @NotNull UntrustedIpRecognitionDecision evaluateAutomaticRecognition( - @Nullable String providerId, - @Nullable String clientIp, - @Nullable ProviderContext selectedProvider - ); - - /** - * Returns whether the given client IP matches a configured untrusted IP or CIDR entry. - * - * @param clientIp current client IP - * @return {@code true} when the IP is configured as untrusted - */ - boolean isUntrustedIp(@Nullable String clientIp); -} diff --git a/identica-api/src/main/java/me/whereareiam/identica/model/config/Providers.java b/identica-api/src/main/java/me/whereareiam/identica/model/config/Providers.java index 4a7008af1..56ed2193a 100644 --- a/identica-api/src/main/java/me/whereareiam/identica/model/config/Providers.java +++ b/identica-api/src/main/java/me/whereareiam/identica/model/config/Providers.java @@ -9,8 +9,8 @@ import me.whereareiam.configura.ConfigDocument; import me.whereareiam.configura.annotation.Merge; import me.whereareiam.configura.merge.strategy.type.StructuralObject; -import me.whereareiam.identica.type.session.RecognitionSignal; import me.whereareiam.identica.type.session.SessionConcurrencyPolicy; +import me.whereareiam.identica.type.session.recognition.RecognitionSignal; import me.whereareiam.identica.type.verification.UnavailableSelectionPolicy; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -97,10 +97,6 @@ public static class ProviderEntry { @Setter @ToString public static class Overrides { - /** - * Restores automatic reconnect recognition for this provider even when the client IP is configured as untrusted. - */ - private boolean allowRecognitionOnUntrustedIp; private @Nullable SessionConcurrencyPolicy sessionConcurrencyPolicy; private @NotNull Recognition recognition = new Recognition(); @@ -110,6 +106,14 @@ public static class Overrides { public static class Recognition { private @Nullable Boolean enabled; private @NotNull List signals = new ArrayList<>(); + private @NotNull Eligibility eligibility = new Eligibility(); + + @Getter + @Setter + @ToString + public static class Eligibility { + private boolean allowOnUntrustedIp; + } } } diff --git a/identica-api/src/main/java/me/whereareiam/identica/model/config/Settings.java b/identica-api/src/main/java/me/whereareiam/identica/model/config/Settings.java index 03ab7fa26..ca2dfa030 100644 --- a/identica-api/src/main/java/me/whereareiam/identica/model/config/Settings.java +++ b/identica-api/src/main/java/me/whereareiam/identica/model/config/Settings.java @@ -14,8 +14,8 @@ import me.whereareiam.identica.type.pipeline.PipelineConcurrencyPolicy; import me.whereareiam.identica.type.pipeline.journey.JourneyMode; import me.whereareiam.identica.type.pipeline.journey.JourneyPolicy; -import me.whereareiam.identica.type.session.RecognitionSignal; import me.whereareiam.identica.type.session.SessionConcurrencyPolicy; +import me.whereareiam.identica.type.session.recognition.RecognitionSignal; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -226,9 +226,9 @@ public static class Recognition { */ private @NotNull List defaultSignals = new ArrayList<>(); /** - * Guard configuration that suppresses automatic reconnect recognition from configured client IP ranges. + * Eligibility rules for whether recognition may be attempted. */ - private @NotNull UntrustedIps untrustedIps = new UntrustedIps(); + private @NotNull Eligibility eligibility = new Eligibility(); public long validityMillis() { if (validity.isZero() || validity.isNegative()) @@ -238,20 +238,30 @@ public long validityMillis() { } /** - * Guard configuration for automatic reconnect recognition coming from untrusted client IPs. + * Eligibility rules for automatic reconnect recognition. */ @Getter @Setter @ToString - public static class UntrustedIps { - /** - * Enables the untrusted-IP suppression guard. - */ - private boolean enabled; + public static class Eligibility { + private @NotNull UntrustedIps untrustedIps = new UntrustedIps(); + /** - * Exact IPs or CIDR ranges that should suppress automatic reconnect recognition. + * Untrusted-IP rules for whether automatic recognition may run. */ - private @NotNull List entries = new ArrayList<>(); + @Getter + @Setter + @ToString + public static class UntrustedIps { + /** + * Enables the untrusted-IP eligibility rule. + */ + private boolean enabled; + /** + * Exact IPs or CIDR ranges that suppress recognition eligibility. + */ + private @NotNull List entries = new ArrayList<>(); + } } } } diff --git a/identica-api/src/main/java/me/whereareiam/identica/model/session/recognition/eligibility/RecognitionEligibilityContext.java b/identica-api/src/main/java/me/whereareiam/identica/model/session/recognition/eligibility/RecognitionEligibilityContext.java new file mode 100644 index 000000000..9c08a03fc --- /dev/null +++ b/identica-api/src/main/java/me/whereareiam/identica/model/session/recognition/eligibility/RecognitionEligibilityContext.java @@ -0,0 +1,29 @@ +package me.whereareiam.identica.model.session.recognition.eligibility; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; +import me.whereareiam.identica.identity.actor.ConnectionIdentity; +import me.whereareiam.identica.model.provider.ProviderContext; +import me.whereareiam.identica.type.session.recognition.RecognitionAttemptKind; +import me.whereareiam.identica.type.session.recognition.RecognitionTrigger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Recognition attempt context used to decide whether recognition may run. + */ +@Getter +@ToString +@Builder(toBuilder = true) +public class RecognitionEligibilityContext { + private final @Nullable String providerId; + private final @Nullable String providerUsername; + private final @Nullable String clientIp; + + private final @Nullable ProviderContext selectedProvider; + private final @Nullable ConnectionIdentity.Origin origin; + + private final @NotNull RecognitionAttemptKind attemptKind; + private final @NotNull RecognitionTrigger trigger; +} diff --git a/identica-api/src/main/java/me/whereareiam/identica/model/session/recognition/eligibility/RecognitionEligibilityDecision.java b/identica-api/src/main/java/me/whereareiam/identica/model/session/recognition/eligibility/RecognitionEligibilityDecision.java new file mode 100644 index 000000000..179f7e70d --- /dev/null +++ b/identica-api/src/main/java/me/whereareiam/identica/model/session/recognition/eligibility/RecognitionEligibilityDecision.java @@ -0,0 +1,18 @@ +package me.whereareiam.identica.model.session.recognition.eligibility; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; +import org.jetbrains.annotations.Nullable; + +/** + * Final eligibility decision for a recognition attempt. + */ +@Getter +@ToString +@Builder(toBuilder = true) +public class RecognitionEligibilityDecision { + private final boolean allowed; + private final @Nullable String reason; + private final @Nullable String ruleId; +} diff --git a/identica-api/src/main/java/me/whereareiam/identica/model/session/recognition/eligibility/RecognitionEligibilityRuleDecision.java b/identica-api/src/main/java/me/whereareiam/identica/model/session/recognition/eligibility/RecognitionEligibilityRuleDecision.java new file mode 100644 index 000000000..4e2682b27 --- /dev/null +++ b/identica-api/src/main/java/me/whereareiam/identica/model/session/recognition/eligibility/RecognitionEligibilityRuleDecision.java @@ -0,0 +1,58 @@ +package me.whereareiam.identica.model.session.recognition.eligibility; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Decision produced by a single recognition eligibility rule. + */ +@Getter +@ToString +@Builder(toBuilder = true) +@AllArgsConstructor +public class RecognitionEligibilityRuleDecision { + private final @NotNull Status status; + private final @Nullable String reason; + + /** + * Creates an abstain decision. + * + * @return abstain decision + */ + public static @NotNull RecognitionEligibilityRuleDecision abstain() { + return new RecognitionEligibilityRuleDecision(Status.ABSTAIN, null); + } + + /** + * Creates an allow decision. + * + * @param reason optional allow reason + * @return allow decision + */ + public static @NotNull RecognitionEligibilityRuleDecision allow(@Nullable String reason) { + return new RecognitionEligibilityRuleDecision(Status.ALLOW, reason); + } + + /** + * Creates a block decision. + * + * @param reason optional block reason + * @return block decision + */ + public static @NotNull RecognitionEligibilityRuleDecision block(@Nullable String reason) { + return new RecognitionEligibilityRuleDecision(Status.BLOCK, reason); + } + + /** + * Per-rule recognition eligibility result. + */ + public enum Status { + ABSTAIN, + ALLOW, + BLOCK + } +} diff --git a/identica-api/src/main/java/me/whereareiam/identica/type/session/recognition/RecognitionAttemptKind.java b/identica-api/src/main/java/me/whereareiam/identica/type/session/recognition/RecognitionAttemptKind.java new file mode 100644 index 000000000..5be227912 --- /dev/null +++ b/identica-api/src/main/java/me/whereareiam/identica/type/session/recognition/RecognitionAttemptKind.java @@ -0,0 +1,9 @@ +package me.whereareiam.identica.type.session.recognition; + +/** + * Recognition attempt category that is being evaluated. + */ +public enum RecognitionAttemptKind { + SESSION_RECOGNITION, + PROVIDER_HANDSHAKE_RECOGNITION +} diff --git a/identica-api/src/main/java/me/whereareiam/identica/type/session/RecognitionSignal.java b/identica-api/src/main/java/me/whereareiam/identica/type/session/recognition/RecognitionSignal.java similarity index 71% rename from identica-api/src/main/java/me/whereareiam/identica/type/session/RecognitionSignal.java rename to identica-api/src/main/java/me/whereareiam/identica/type/session/recognition/RecognitionSignal.java index a32aa0bd0..3a9385159 100644 --- a/identica-api/src/main/java/me/whereareiam/identica/type/session/RecognitionSignal.java +++ b/identica-api/src/main/java/me/whereareiam/identica/type/session/recognition/RecognitionSignal.java @@ -1,4 +1,4 @@ -package me.whereareiam.identica.type.session; +package me.whereareiam.identica.type.session.recognition; /** * Signals that may be matched before a reconnecting player is recognized. diff --git a/identica-api/src/main/java/me/whereareiam/identica/type/session/recognition/RecognitionTrigger.java b/identica-api/src/main/java/me/whereareiam/identica/type/session/recognition/RecognitionTrigger.java new file mode 100644 index 000000000..971964f3f --- /dev/null +++ b/identica-api/src/main/java/me/whereareiam/identica/type/session/recognition/RecognitionTrigger.java @@ -0,0 +1,9 @@ +package me.whereareiam.identica.type.session.recognition; + +/** + * Trigger that caused a recognition attempt to be evaluated. + */ +public enum RecognitionTrigger { + AUTOMATIC, + EXPLICIT_PROVIDER_SELECTION +} diff --git a/identica-common/src/main/java/me/whereareiam/identica/common/CommonConfiguration.java b/identica-common/src/main/java/me/whereareiam/identica/common/CommonConfiguration.java index 06694492d..75d0440ec 100644 --- a/identica-common/src/main/java/me/whereareiam/identica/common/CommonConfiguration.java +++ b/identica-common/src/main/java/me/whereareiam/identica/common/CommonConfiguration.java @@ -30,7 +30,11 @@ import me.whereareiam.identica.common.identity.session.DefaultSessionService; import me.whereareiam.identica.common.identity.session.recognition.DefaultSessionRecognitionService; import me.whereareiam.identica.common.identity.session.recognition.DefaultSessionRecognitionStore; -import me.whereareiam.identica.common.identity.session.recognition.policy.UntrustedIpRecognitionGuard; +import me.whereareiam.identica.common.identity.session.recognition.eligibility.DefaultRecognitionEligibilityRegistry; +import me.whereareiam.identica.common.identity.session.recognition.eligibility.DefaultRecognitionEligibilityService; +import me.whereareiam.identica.common.identity.session.recognition.eligibility.rule.ExplicitSelectionRecognitionEligibilityRule; +import me.whereareiam.identica.common.identity.session.recognition.eligibility.rule.RecognitionEnabledEligibilityRule; +import me.whereareiam.identica.common.identity.session.recognition.eligibility.rule.UntrustedIpRecognitionEligibilityRule; import me.whereareiam.identica.common.listener.DefaultDynamicListenerRegistry; import me.whereareiam.identica.common.listener.SessionClosedDisconnectListener; import me.whereareiam.identica.common.listener.SessionReplacedListener; @@ -70,7 +74,8 @@ import me.whereareiam.identica.identity.session.SessionService; import me.whereareiam.identica.identity.session.recognition.SessionRecognitionService; import me.whereareiam.identica.identity.session.recognition.SessionRecognitionStore; -import me.whereareiam.identica.identity.session.recognition.policy.UntrustedIpRecognitionPolicy; +import me.whereareiam.identica.identity.session.recognition.eligibility.RecognitionEligibilityRegistry; +import me.whereareiam.identica.identity.session.recognition.eligibility.RecognitionEligibilityService; import me.whereareiam.identica.listener.DynamicListenerRegistry; import me.whereareiam.identica.logging.BannerContributor; import me.whereareiam.identica.model.config.*; @@ -176,6 +181,8 @@ protected void configure() { bind(SessionService.class).to(DefaultSessionService.class).asEagerSingleton(); bind(SessionRecognitionStore.class).to(DefaultSessionRecognitionStore.class).asEagerSingleton(); bind(SessionRecognitionService.class).to(DefaultSessionRecognitionService.class).asEagerSingleton(); + bind(RecognitionEligibilityRegistry.class).to(DefaultRecognitionEligibilityRegistry.class).asEagerSingleton(); + bind(RecognitionEligibilityService.class).to(DefaultRecognitionEligibilityService.class).asEagerSingleton(); // Platform adaptation helpers bind(ConnectionDecisionApplier.class).asEagerSingleton(); @@ -207,7 +214,6 @@ protected void configure() { // Provider system bind(ProviderDescriptorReader.class).to(DefaultProviderDescriptorReader.class).asEagerSingleton(); bind(ProviderManager.class).to(DefaultProviderManager.class).asEagerSingleton(); - bind(UntrustedIpRecognitionPolicy.class).to(UntrustedIpRecognitionGuard.class).asEagerSingleton(); bind(ProviderOperations.class).to(DefaultProviderOperations.class).asEagerSingleton(); // Core services @@ -227,6 +233,18 @@ void initializeEventUtil(EventManager eventManager) { EventUtil.initialize(eventManager); } + @Inject + void initializeRecognitionEligibilityRules( + RecognitionEligibilityRegistry registry, + RecognitionEnabledEligibilityRule recognitionEnabledEligibilityRule, + ExplicitSelectionRecognitionEligibilityRule explicitSelectionRecognitionEligibilityRule, + UntrustedIpRecognitionEligibilityRule untrustedIpRecognitionEligibilityRule + ) { + registry.register(recognitionEnabledEligibilityRule); + registry.register(explicitSelectionRecognitionEligibilityRule); + registry.register(untrustedIpRecognitionEligibilityRule); + } + @Inject void initializeConfigura() { Path configuredDataPath = ensureDirectory(dataPath, "data"); diff --git a/identica-common/src/main/java/me/whereareiam/identica/common/config/defaults/SettingsDefaults.java b/identica-common/src/main/java/me/whereareiam/identica/common/config/defaults/SettingsDefaults.java index 7ddde4b1a..67fdd61e9 100644 --- a/identica-common/src/main/java/me/whereareiam/identica/common/config/defaults/SettingsDefaults.java +++ b/identica-common/src/main/java/me/whereareiam/identica/common/config/defaults/SettingsDefaults.java @@ -12,8 +12,8 @@ import me.whereareiam.identica.type.pipeline.PipelineConcurrencyPolicy; import me.whereareiam.identica.type.pipeline.journey.JourneyMode; import me.whereareiam.identica.type.pipeline.journey.JourneyPolicy; -import me.whereareiam.identica.type.session.RecognitionSignal; import me.whereareiam.identica.type.session.SessionConcurrencyPolicy; +import me.whereareiam.identica.type.session.recognition.RecognitionSignal; import java.time.Duration; import java.util.HashMap; @@ -46,7 +46,8 @@ public Settings supply(Settings settings) { RecognitionSignal.IP, RecognitionSignal.VIRTUAL_HOST )); - Settings.Sessions.Recognition.UntrustedIps untrustedIps = new Settings.Sessions.Recognition.UntrustedIps(); + Settings.Sessions.Recognition.Eligibility.UntrustedIps untrustedIps = + new Settings.Sessions.Recognition.Eligibility.UntrustedIps(); untrustedIps.setEnabled(true); untrustedIps.setEntries(List.of( "127.0.0.1", @@ -55,7 +56,9 @@ public Settings supply(Settings settings) { "172.16.0.0/12", "192.168.0.0/16" )); - recognition.setUntrustedIps(untrustedIps); + Settings.Sessions.Recognition.Eligibility eligibility = new Settings.Sessions.Recognition.Eligibility(); + eligibility.setUntrustedIps(untrustedIps); + recognition.setEligibility(eligibility); sessions.setRecognition(recognition); Settings.Connection connection = new Settings.Connection(); diff --git a/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/DefaultSessionRecognitionService.java b/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/DefaultSessionRecognitionService.java index 0328c6d0d..5b8ccab76 100644 --- a/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/DefaultSessionRecognitionService.java +++ b/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/DefaultSessionRecognitionService.java @@ -7,11 +7,14 @@ import me.whereareiam.identica.identity.actor.ConnectionIdentity; import me.whereareiam.identica.identity.session.recognition.SessionRecognitionService; import me.whereareiam.identica.identity.session.recognition.SessionRecognitionStore; -import me.whereareiam.identica.identity.session.recognition.policy.UntrustedIpRecognitionPolicy; +import me.whereareiam.identica.identity.session.recognition.eligibility.RecognitionEligibilityService; import me.whereareiam.identica.model.config.Providers; import me.whereareiam.identica.model.config.Settings; import me.whereareiam.identica.model.session.SessionRecognitionSnapshot; -import me.whereareiam.identica.type.session.RecognitionSignal; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityContext; +import me.whereareiam.identica.type.session.recognition.RecognitionAttemptKind; +import me.whereareiam.identica.type.session.recognition.RecognitionSignal; +import me.whereareiam.identica.type.session.recognition.RecognitionTrigger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -26,7 +29,7 @@ public class DefaultSessionRecognitionService implements SessionRecognitionServi private final Provider settingsProvider; private final Provider providersProvider; private final SessionRecognitionStore sessionRecognitionStore; - private final UntrustedIpRecognitionPolicy untrustedIpRecognitionPolicy; + private final RecognitionEligibilityService recognitionEligibilityService; @Override public boolean matches( @@ -38,7 +41,14 @@ public boolean matches( ) { if (isBlank(providerId) || isBlank(providerSubject) || !isRecognitionEnabled(providerId)) return false; - if (untrustedIpRecognitionPolicy.evaluateAutomaticRecognition(providerId, ip, null).isBlocked()) + if (!recognitionEligibilityService.evaluate(RecognitionEligibilityContext.builder() + .providerId(providerId) + .providerUsername(providerUsername) + .clientIp(ip) + .origin(origin) + .attemptKind(RecognitionAttemptKind.SESSION_RECOGNITION) + .trigger(RecognitionTrigger.AUTOMATIC) + .build()).isAllowed()) return false; Optional storedOptional = sessionRecognitionStore.find(providerId.trim(), providerSubject.trim()); diff --git a/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/DefaultRecognitionEligibilityRegistry.java b/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/DefaultRecognitionEligibilityRegistry.java new file mode 100644 index 000000000..3bf7830c5 --- /dev/null +++ b/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/DefaultRecognitionEligibilityRegistry.java @@ -0,0 +1,42 @@ +package me.whereareiam.identica.common.identity.session.recognition.eligibility; + +import com.google.inject.Singleton; +import me.whereareiam.identica.identity.session.recognition.eligibility.RecognitionEligibilityRegistry; +import me.whereareiam.identica.identity.session.recognition.eligibility.RecognitionEligibilityRule; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Locale; +import java.util.concurrent.CopyOnWriteArrayList; + +@Singleton +public class DefaultRecognitionEligibilityRegistry implements RecognitionEligibilityRegistry { + private final List rules = new CopyOnWriteArrayList<>(); + + @Override + public void register(@NotNull RecognitionEligibilityRule rule) { + String id = rule.id(); + if (id.isBlank()) return; + + String normalized = normalize(id); + rules.removeIf(existing -> existing != null && normalize(existing.id()).equals(normalized)); + rules.add(rule); + } + + @Override + public boolean unregister(@NotNull String ruleId) { + if (ruleId.isBlank()) return false; + + String normalized = normalize(ruleId); + return rules.removeIf(existing -> existing != null && normalize(existing.id()).equals(normalized)); + } + + @Override + public @NotNull List resolve() { + return List.copyOf(rules); + } + + private @NotNull String normalize(@NotNull String value) { + return value.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/DefaultRecognitionEligibilityService.java b/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/DefaultRecognitionEligibilityService.java new file mode 100644 index 000000000..5d701286b --- /dev/null +++ b/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/DefaultRecognitionEligibilityService.java @@ -0,0 +1,62 @@ +package me.whereareiam.identica.common.identity.session.recognition.eligibility; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import lombok.RequiredArgsConstructor; +import me.whereareiam.identica.identity.session.recognition.eligibility.RecognitionEligibilityRegistry; +import me.whereareiam.identica.identity.session.recognition.eligibility.RecognitionEligibilityRule; +import me.whereareiam.identica.identity.session.recognition.eligibility.RecognitionEligibilityService; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityContext; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityDecision; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityRuleDecision; +import org.jetbrains.annotations.NotNull; + +import java.util.Comparator; + +@Singleton +@RequiredArgsConstructor(onConstructor_ = @Inject) +public class DefaultRecognitionEligibilityService implements RecognitionEligibilityService { + private final RecognitionEligibilityRegistry registry; + + @Override + public @NotNull RecognitionEligibilityDecision evaluate(@NotNull RecognitionEligibilityContext context) { + RecognitionEligibilityDecision allowed = RecognitionEligibilityDecision.builder() + .allowed(true) + .reason("no-rule-blocked") + .build(); + + return registry.resolve().stream() + .sorted(Comparator.comparingInt(RecognitionEligibilityRule::order) + .thenComparing(rule -> rule.id().trim().toLowerCase(java.util.Locale.ROOT))) + .filter(rule -> rule.supports(context)) + .reduce( + allowed, + (current, rule) -> evaluateRule(current, rule, context), + (left, right) -> right + ); + } + + private @NotNull RecognitionEligibilityDecision evaluateRule( + @NotNull RecognitionEligibilityDecision current, + @NotNull RecognitionEligibilityRule rule, + @NotNull RecognitionEligibilityContext context + ) { + if (!current.isAllowed()) return current; + + RecognitionEligibilityRuleDecision decision = rule.evaluate(context); + if (decision.getStatus() == RecognitionEligibilityRuleDecision.Status.ABSTAIN) return current; + if (decision.getStatus() == RecognitionEligibilityRuleDecision.Status.BLOCK) + return RecognitionEligibilityDecision.builder() + .allowed(false) + .reason(decision.getReason()) + .ruleId(rule.id()) + .build(); + + if (current.getRuleId() != null) return current; + return RecognitionEligibilityDecision.builder() + .allowed(true) + .reason(decision.getReason()) + .ruleId(rule.id()) + .build(); + } +} diff --git a/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/policy/matcher/UntrustedIpMatcher.java b/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/matcher/IpMatcher.java similarity index 76% rename from identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/policy/matcher/UntrustedIpMatcher.java rename to identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/matcher/IpMatcher.java index eefd132b3..867683c00 100644 --- a/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/policy/matcher/UntrustedIpMatcher.java +++ b/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/matcher/IpMatcher.java @@ -1,9 +1,9 @@ -package me.whereareiam.identica.common.identity.session.recognition.policy.matcher; +package me.whereareiam.identica.common.identity.session.recognition.eligibility.matcher; import org.jetbrains.annotations.NotNull; import java.net.InetAddress; -public interface UntrustedIpMatcher { +public interface IpMatcher { boolean matches(@NotNull InetAddress address); } diff --git a/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/matcher/type/CidrMatcher.java b/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/matcher/type/CidrMatcher.java new file mode 100644 index 000000000..a502cbe00 --- /dev/null +++ b/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/matcher/type/CidrMatcher.java @@ -0,0 +1,30 @@ +package me.whereareiam.identica.common.identity.session.recognition.eligibility.matcher.type; + +import lombok.RequiredArgsConstructor; +import me.whereareiam.identica.common.identity.session.recognition.eligibility.matcher.IpMatcher; +import org.jetbrains.annotations.NotNull; + +import java.net.InetAddress; +import java.util.Arrays; + +@RequiredArgsConstructor +public final class CidrMatcher implements IpMatcher { + private final @NotNull InetAddress baseAddress; + private final int prefixLength; + + @Override + public boolean matches(@NotNull InetAddress address) { + byte[] left = baseAddress.getAddress(); + byte[] right = address.getAddress(); + if (left.length != right.length) return false; + + int fullBytes = prefixLength / 8; + int remainingBits = prefixLength % 8; + if (!Arrays.equals(Arrays.copyOf(left, fullBytes), Arrays.copyOf(right, fullBytes))) + return false; + if (remainingBits == 0) return true; + + int mask = -(1 << (8 - remainingBits)) & 0xFF; + return (left[fullBytes] & mask) == (right[fullBytes] & mask); + } +} diff --git a/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/matcher/type/ExactIpMatcher.java b/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/matcher/type/ExactIpMatcher.java new file mode 100644 index 000000000..8e26c60c0 --- /dev/null +++ b/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/matcher/type/ExactIpMatcher.java @@ -0,0 +1,17 @@ +package me.whereareiam.identica.common.identity.session.recognition.eligibility.matcher.type; + +import lombok.RequiredArgsConstructor; +import me.whereareiam.identica.common.identity.session.recognition.eligibility.matcher.IpMatcher; +import org.jetbrains.annotations.NotNull; + +import java.net.InetAddress; + +@RequiredArgsConstructor +public final class ExactIpMatcher implements IpMatcher { + private final @NotNull InetAddress address; + + @Override + public boolean matches(@NotNull InetAddress value) { + return address.equals(value); + } +} diff --git a/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/ExplicitSelectionRecognitionEligibilityRule.java b/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/ExplicitSelectionRecognitionEligibilityRule.java new file mode 100644 index 000000000..5a30f1e9e --- /dev/null +++ b/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/ExplicitSelectionRecognitionEligibilityRule.java @@ -0,0 +1,44 @@ +package me.whereareiam.identica.common.identity.session.recognition.eligibility.rule; + +import com.google.inject.Singleton; +import me.whereareiam.identica.identity.session.recognition.eligibility.RecognitionEligibilityRule; +import me.whereareiam.identica.model.provider.ProviderContext; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityContext; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityRuleDecision; +import me.whereareiam.identica.type.provider.ProviderOrigin; +import me.whereareiam.identica.type.session.recognition.RecognitionAttemptKind; +import org.jetbrains.annotations.NotNull; + +@Singleton +public class ExplicitSelectionRecognitionEligibilityRule implements RecognitionEligibilityRule { + @Override + public @NotNull String id() { + return "explicit-selection"; + } + + @Override + public int order() { + return 50; + } + + @Override + public boolean supports(@NotNull RecognitionEligibilityContext context) { + return context.getAttemptKind() == RecognitionAttemptKind.PROVIDER_HANDSHAKE_RECOGNITION; + } + + @Override + public @NotNull RecognitionEligibilityRuleDecision evaluate(@NotNull RecognitionEligibilityContext context) { + ProviderContext provider = context.getSelectedProvider(); + String providerId = context.getProviderId(); + if (provider == null || providerId == null || providerId.isBlank()) + return RecognitionEligibilityRuleDecision.abstain(); + if (provider.getProviderId() == null || !provider.getProviderId().equalsIgnoreCase(providerId)) + return RecognitionEligibilityRuleDecision.abstain(); + + ProviderOrigin source = provider.getSource(); + if (source == ProviderOrigin.ENTRYPOINT || source == ProviderOrigin.MANUAL) + return RecognitionEligibilityRuleDecision.allow("explicit-provider-selection"); + + return RecognitionEligibilityRuleDecision.abstain(); + } +} diff --git a/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/RecognitionEnabledEligibilityRule.java b/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/RecognitionEnabledEligibilityRule.java new file mode 100644 index 000000000..6e47233f6 --- /dev/null +++ b/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/RecognitionEnabledEligibilityRule.java @@ -0,0 +1,66 @@ +package me.whereareiam.identica.common.identity.session.recognition.eligibility.rule; + +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.Singleton; +import lombok.RequiredArgsConstructor; +import me.whereareiam.identica.identity.session.recognition.eligibility.RecognitionEligibilityRule; +import me.whereareiam.identica.model.config.Providers; +import me.whereareiam.identica.model.config.Settings; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityContext; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityRuleDecision; +import me.whereareiam.identica.type.session.recognition.RecognitionAttemptKind; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@Singleton +@RequiredArgsConstructor(onConstructor_ = @Inject) +public class RecognitionEnabledEligibilityRule implements RecognitionEligibilityRule { + private final Provider settingsProvider; + private final Provider providersProvider; + + @Override + public @NotNull String id() { + return "recognition-enabled"; + } + + @Override + public int order() { + return 0; + } + + @Override + public boolean supports(@NotNull RecognitionEligibilityContext context) { + return context.getAttemptKind() == RecognitionAttemptKind.SESSION_RECOGNITION; + } + + @Override + public @NotNull RecognitionEligibilityRuleDecision evaluate(@NotNull RecognitionEligibilityContext context) { + String providerId = context.getProviderId(); + if (providerId == null || providerId.isBlank()) + return RecognitionEligibilityRuleDecision.block("recognition-provider-missing"); + if (!isRecognitionEnabled(providerId)) + return RecognitionEligibilityRuleDecision.block("recognition-disabled"); + + return RecognitionEligibilityRuleDecision.abstain(); + } + + private boolean isRecognitionEnabled(@NotNull String providerId) { + Providers.ProviderEntry provider = findProvider(providerId); + Providers.Overrides.Recognition overrides = provider != null ? provider.getOverrides().getRecognition() : null; + Boolean override = overrides != null ? overrides.getEnabled() : null; + if (override != null) return override; + + return settingsProvider.get().getConnection().getSessions().getRecognition().isEnabled(); + } + + private @Nullable Providers.ProviderEntry findProvider(@Nullable String rawId) { + if (rawId == null || rawId.isBlank()) return null; + + for (Providers.ProviderEntry provider : providersProvider.get().getProviders()) { + if (provider == null || provider.getId().isBlank()) continue; + if (provider.getId().trim().equalsIgnoreCase(rawId.trim())) return provider; + } + return null; + } +} diff --git a/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/policy/UntrustedIpRecognitionGuard.java b/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/UntrustedIpRecognitionEligibilityRule.java similarity index 61% rename from identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/policy/UntrustedIpRecognitionGuard.java rename to identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/UntrustedIpRecognitionEligibilityRule.java index bc3d9b36c..8e138e76c 100644 --- a/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/policy/UntrustedIpRecognitionGuard.java +++ b/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/UntrustedIpRecognitionEligibilityRule.java @@ -1,19 +1,17 @@ -package me.whereareiam.identica.common.identity.session.recognition.policy; +package me.whereareiam.identica.common.identity.session.recognition.eligibility.rule; import com.google.inject.Inject; import com.google.inject.Provider; import com.google.inject.Singleton; import lombok.RequiredArgsConstructor; -import me.whereareiam.identica.common.identity.session.recognition.policy.matcher.UntrustedIpMatcher; -import me.whereareiam.identica.common.identity.session.recognition.policy.matcher.type.CidrMatcher; -import me.whereareiam.identica.common.identity.session.recognition.policy.matcher.type.ExactIpMatcher; -import me.whereareiam.identica.identity.session.recognition.policy.UntrustedIpRecognitionDecision; -import me.whereareiam.identica.identity.session.recognition.policy.UntrustedIpRecognitionDecision.Outcome; -import me.whereareiam.identica.identity.session.recognition.policy.UntrustedIpRecognitionPolicy; +import me.whereareiam.identica.common.identity.session.recognition.eligibility.matcher.IpMatcher; +import me.whereareiam.identica.common.identity.session.recognition.eligibility.matcher.type.CidrMatcher; +import me.whereareiam.identica.common.identity.session.recognition.eligibility.matcher.type.ExactIpMatcher; +import me.whereareiam.identica.identity.session.recognition.eligibility.RecognitionEligibilityRule; import me.whereareiam.identica.model.config.Providers; import me.whereareiam.identica.model.config.Settings; -import me.whereareiam.identica.model.provider.ProviderContext; -import me.whereareiam.identica.type.provider.ProviderOrigin; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityContext; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityRuleDecision; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -23,42 +21,44 @@ @Singleton @RequiredArgsConstructor(onConstructor_ = @Inject) -public class UntrustedIpRecognitionGuard implements UntrustedIpRecognitionPolicy { +public class UntrustedIpRecognitionEligibilityRule implements RecognitionEligibilityRule { private final Provider settingsProvider; private final Provider providersProvider; @Override - public @NotNull UntrustedIpRecognitionDecision evaluateAutomaticRecognition( - @Nullable String providerId, - @Nullable String clientIp, - @Nullable ProviderContext selectedProvider - ) { - Settings.Sessions.Recognition.UntrustedIps untrustedIps = settingsProvider.get() + public @NotNull String id() { + return "untrusted-ip"; + } + + @Override + public int order() { + return 100; + } + + @Override + public @NotNull RecognitionEligibilityRuleDecision evaluate(@NotNull RecognitionEligibilityContext context) { + Settings.Sessions.Recognition.Eligibility.UntrustedIps untrustedIps = settingsProvider.get() .getConnection() .getSessions() .getRecognition() + .getEligibility() .getUntrustedIps(); - if (!untrustedIps.isEnabled()) return decision(Outcome.ALLOWED_DISABLED); - if (allowsRecognitionOverride(providerId)) return decision(Outcome.ALLOWED_PROVIDER_OVERRIDE); - if (isExplicitSelection(selectedProvider, providerId)) - return decision(Outcome.ALLOWED_EXPLICIT_SELECTION); + if (!untrustedIps.isEnabled()) return RecognitionEligibilityRuleDecision.abstain(); + if (allowsRecognitionOverride(context.getProviderId())) + return RecognitionEligibilityRuleDecision.allow("provider-override-untrusted-ip"); - InetAddress clientAddress = parseClientAddress(clientIp); - if (clientAddress == null) return decision(Outcome.ALLOWED_MISSING_IP); + InetAddress clientAddress = parseClientAddress(context.getClientIp()); + if (clientAddress == null) return RecognitionEligibilityRuleDecision.abstain(); List entries = untrustedIps.getEntries(); for (int i = 0; i < entries.size(); i++) { - UntrustedIpMatcher matcher = parseEntry(entries.get(i), i); - if (matcher.matches(clientAddress)) return decision(Outcome.BLOCKED_UNTRUSTED_IP); + IpMatcher matcher = parseEntry(entries.get(i), i); + if (matcher.matches(clientAddress)) + return RecognitionEligibilityRuleDecision.block("blocked-untrusted-ip"); } - return decision(Outcome.ALLOWED_IP_NOT_MATCHED); - } - - @Override - public boolean isUntrustedIp(@Nullable String clientIp) { - return evaluateAutomaticRecognition(null, clientIp, null).isBlocked(); + return RecognitionEligibilityRuleDecision.abstain(); } private boolean allowsRecognitionOverride(@Nullable String providerId) { @@ -67,26 +67,13 @@ private boolean allowsRecognitionOverride(@Nullable String providerId) { for (Providers.ProviderEntry entry : providersProvider.get().getProviders()) { if (entry == null) continue; if (!entry.getId().equalsIgnoreCase(providerId)) continue; - return entry.getOverrides().isAllowRecognitionOnUntrustedIp(); + return entry.getOverrides().getRecognition().getEligibility().isAllowOnUntrustedIp(); } return false; } - private boolean isExplicitSelection(@Nullable ProviderContext selectedProvider, @Nullable String providerId) { - if (selectedProvider == null || providerId == null || providerId.isBlank()) return false; - if (selectedProvider.getProviderId() == null || !selectedProvider.getProviderId().equalsIgnoreCase(providerId)) - return false; - - ProviderOrigin source = selectedProvider.getSource(); - return source == ProviderOrigin.ENTRYPOINT || source == ProviderOrigin.MANUAL; - } - - private @NotNull UntrustedIpRecognitionDecision decision(@NotNull Outcome outcome) { - return new UntrustedIpRecognitionDecision(outcome); - } - - private @NotNull UntrustedIpMatcher parseEntry(@Nullable String entry, int index) { + private @NotNull IpMatcher parseEntry(@Nullable String entry, int index) { if (entry == null || entry.isBlank()) throw invalidEntry(index, entry, "entry must not be blank"); String trimmed = entry.trim(); @@ -96,7 +83,7 @@ private boolean isExplicitSelection(@Nullable ProviderContext selectedProvider, return new ExactIpMatcher(literal); } - private @NotNull UntrustedIpMatcher parseCidr(@NotNull String entry, int index) { + private @NotNull IpMatcher parseCidr(@NotNull String entry, int index) { String[] parts = entry.split("/", -1); if (parts.length != 2) throw invalidEntry(index, entry, "CIDR entry must contain exactly one '/' separator"); @@ -173,7 +160,7 @@ private boolean isHexDigit(int character) { private @NotNull IllegalStateException invalidEntry(int index, @Nullable String entry, @NotNull String reason) { return new IllegalStateException( - "settings.connection.sessions.recognition.untrustedIps.entries[" + index + "]=" + "settings.connection.sessions.recognition.eligibility.untrustedIps.entries[" + index + "]=" + entry + " is invalid: " + reason diff --git a/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/policy/matcher/type/CidrMatcher.java b/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/policy/matcher/type/CidrMatcher.java deleted file mode 100644 index 45bebced8..000000000 --- a/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/policy/matcher/type/CidrMatcher.java +++ /dev/null @@ -1,40 +0,0 @@ -package me.whereareiam.identica.common.identity.session.recognition.policy.matcher.type; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.ToString; -import me.whereareiam.identica.common.identity.session.recognition.policy.matcher.UntrustedIpMatcher; -import org.jetbrains.annotations.NotNull; - -import java.net.InetAddress; - -@Getter -@ToString -@AllArgsConstructor -public final class CidrMatcher implements UntrustedIpMatcher { - private final @NotNull InetAddress base; - private final int prefix; - - @Override - public boolean matches(@NotNull InetAddress address) { - byte[] left = base.getAddress(); - byte[] right = address.getAddress(); - if (left.length != right.length) - return false; - - int remaining = prefix; - for (int i = 0; i < left.length; i++) { - if (remaining <= 0) - return true; - - int bits = Math.min(remaining, 8); - int mask = bits == 8 ? 0xFF : (0xFF << (8 - bits)) & 0xFF; - if ((left[i] & mask) != (right[i] & mask)) - return false; - - remaining -= bits; - } - - return true; - } -} diff --git a/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/policy/matcher/type/ExactIpMatcher.java b/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/policy/matcher/type/ExactIpMatcher.java deleted file mode 100644 index e3b418989..000000000 --- a/identica-common/src/main/java/me/whereareiam/identica/common/identity/session/recognition/policy/matcher/type/ExactIpMatcher.java +++ /dev/null @@ -1,21 +0,0 @@ -package me.whereareiam.identica.common.identity.session.recognition.policy.matcher.type; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.ToString; -import me.whereareiam.identica.common.identity.session.recognition.policy.matcher.UntrustedIpMatcher; -import org.jetbrains.annotations.NotNull; - -import java.net.InetAddress; - -@Getter -@ToString -@AllArgsConstructor -public final class ExactIpMatcher implements UntrustedIpMatcher { - private final @NotNull InetAddress expected; - - @Override - public boolean matches(@NotNull InetAddress address) { - return expected.equals(address); - } -} diff --git a/identica-common/src/test/java/me/whereareiam/identica/common/config/defaults/ProvidersDefaultsTest.java b/identica-common/src/test/java/me/whereareiam/identica/common/config/defaults/ProvidersDefaultsTest.java index bfe68ffd1..70e2a25ee 100644 --- a/identica-common/src/test/java/me/whereareiam/identica/common/config/defaults/ProvidersDefaultsTest.java +++ b/identica-common/src/test/java/me/whereareiam/identica/common/config/defaults/ProvidersDefaultsTest.java @@ -13,12 +13,12 @@ class ProvidersDefaultsTest { void providerRecognitionOverridesDefaultToDisabled() { Providers providers = new ProvidersDefaults().supply(new Providers()); - assertFalse(provider(providers, "premium").getOverrides().isAllowRecognitionOnUntrustedIp()); assertTrue(provider(providers, "premium").getOverrides().getRecognition().getSignals().isEmpty()); assertNull(provider(providers, "premium").getOverrides().getRecognition().getEnabled()); - assertFalse(provider(providers, "credential").getOverrides().isAllowRecognitionOnUntrustedIp()); + assertFalse(provider(providers, "premium").getOverrides().getRecognition().getEligibility().isAllowOnUntrustedIp()); assertTrue(provider(providers, "credential").getOverrides().getRecognition().getSignals().isEmpty()); assertNull(provider(providers, "credential").getOverrides().getRecognition().getEnabled()); + assertFalse(provider(providers, "credential").getOverrides().getRecognition().getEligibility().isAllowOnUntrustedIp()); } private Providers.ProviderEntry provider(Providers providers, String id) { diff --git a/identica-common/src/test/java/me/whereareiam/identica/common/config/defaults/SettingsDefaultsTest.java b/identica-common/src/test/java/me/whereareiam/identica/common/config/defaults/SettingsDefaultsTest.java index e53065048..fb6c43e81 100644 --- a/identica-common/src/test/java/me/whereareiam/identica/common/config/defaults/SettingsDefaultsTest.java +++ b/identica-common/src/test/java/me/whereareiam/identica/common/config/defaults/SettingsDefaultsTest.java @@ -7,7 +7,7 @@ import me.whereareiam.identica.model.config.Settings; import me.whereareiam.identica.type.PlatformType; import me.whereareiam.identica.type.event.EventPriority; -import me.whereareiam.identica.type.session.RecognitionSignal; +import me.whereareiam.identica.type.session.recognition.RecognitionSignal; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -35,6 +35,7 @@ void generatedRoutingScenariosAreEmptyByDefault() { java.util.List.of(RecognitionSignal.USERNAME, RecognitionSignal.IP, RecognitionSignal.VIRTUAL_HOST), settings.getConnection().getSessions().getRecognition().getDefaultSignals() ); + assertTrue(settings.getConnection().getSessions().getRecognition().getEligibility().getUntrustedIps().isEnabled()); assertEquals(java.time.Duration.ofHours(12), settings.getConnection().getSessions().getActiveTtl()); assertEquals(java.time.Duration.ofHours(12), settings.getConnection().getSessions().getRecognition().getValidity()); } diff --git a/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/DefaultSessionRecognitionServiceTest.java b/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/DefaultSessionRecognitionServiceTest.java index ccaaa2730..8a19b4de7 100644 --- a/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/DefaultSessionRecognitionServiceTest.java +++ b/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/DefaultSessionRecognitionServiceTest.java @@ -1,12 +1,15 @@ package me.whereareiam.identica.common.identity.session.recognition; -import me.whereareiam.identica.common.identity.session.recognition.policy.UntrustedIpRecognitionGuard; +import me.whereareiam.identica.common.identity.session.recognition.eligibility.DefaultRecognitionEligibilityRegistry; +import me.whereareiam.identica.common.identity.session.recognition.eligibility.DefaultRecognitionEligibilityService; +import me.whereareiam.identica.common.identity.session.recognition.eligibility.rule.RecognitionEnabledEligibilityRule; +import me.whereareiam.identica.common.identity.session.recognition.eligibility.rule.UntrustedIpRecognitionEligibilityRule; import me.whereareiam.identica.identity.actor.ConnectionIdentity; import me.whereareiam.identica.identity.session.recognition.SessionRecognitionStore; import me.whereareiam.identica.model.config.Providers; import me.whereareiam.identica.model.config.Settings; import me.whereareiam.identica.model.session.SessionRecognitionSnapshot; -import me.whereareiam.identica.type.session.RecognitionSignal; +import me.whereareiam.identica.type.session.recognition.RecognitionSignal; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -71,11 +74,15 @@ private DefaultSessionRecognitionService service( Providers providers, SessionRecognitionStore store ) { + DefaultRecognitionEligibilityRegistry registry = new DefaultRecognitionEligibilityRegistry(); + registry.register(new RecognitionEnabledEligibilityRule(() -> settings, () -> providers)); + registry.register(new UntrustedIpRecognitionEligibilityRule(() -> settings, () -> providers)); + return new DefaultSessionRecognitionService( () -> settings, () -> providers, store, - new UntrustedIpRecognitionGuard(() -> settings, () -> providers) + new DefaultRecognitionEligibilityService(registry) ); } @@ -89,15 +96,19 @@ private Settings settings( List signals, List untrustedIps ) { - Settings.Sessions.Recognition.UntrustedIps untrusted = new Settings.Sessions.Recognition.UntrustedIps(); + Settings.Sessions.Recognition.Eligibility.UntrustedIps untrusted = + new Settings.Sessions.Recognition.Eligibility.UntrustedIps(); untrusted.setEnabled(true); untrusted.setEntries(untrustedIps); + Settings.Sessions.Recognition.Eligibility eligibility = new Settings.Sessions.Recognition.Eligibility(); + eligibility.setUntrustedIps(untrusted); + Settings.Sessions.Recognition recognition = new Settings.Sessions.Recognition(); recognition.setEnabled(true); recognition.setValidity(Duration.ofHours(12)); recognition.setDefaultSignals(signals); - recognition.setUntrustedIps(untrusted); + recognition.setEligibility(eligibility); Settings.Sessions sessions = new Settings.Sessions(); sessions.setRecognition(recognition); @@ -113,7 +124,7 @@ private Settings settings( private Providers providers(boolean allowRecognitionOnUntrustedIp) { Providers.ProviderEntry provider = new Providers.ProviderEntry(); provider.setId("credential"); - provider.getOverrides().setAllowRecognitionOnUntrustedIp(allowRecognitionOnUntrustedIp); + provider.getOverrides().getRecognition().getEligibility().setAllowOnUntrustedIp(allowRecognitionOnUntrustedIp); Providers providers = new Providers(); providers.setProviders(List.of(provider)); diff --git a/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/DefaultRecognitionEligibilityRegistryTest.java b/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/DefaultRecognitionEligibilityRegistryTest.java new file mode 100644 index 000000000..aaccb3f8f --- /dev/null +++ b/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/DefaultRecognitionEligibilityRegistryTest.java @@ -0,0 +1,55 @@ +package me.whereareiam.identica.common.identity.session.recognition.eligibility; + +import me.whereareiam.identica.identity.session.recognition.eligibility.RecognitionEligibilityRule; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityContext; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityRuleDecision; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Default Recognition Eligibility Registry") +class DefaultRecognitionEligibilityRegistryTest { + @DisplayName("Registers, replaces, and unregisters rules by id") + @Test + void registersReplacesAndUnregistersRulesById() { + DefaultRecognitionEligibilityRegistry registry = new DefaultRecognitionEligibilityRegistry(); + + registry.register(rule("corporate", 10)); + registry.register(rule("Corporate", 20)); + + assertEquals(1, registry.resolve().size()); + assertEquals(20, registry.resolve().getFirst().order()); + assertTrue(registry.unregister("CORPORATE")); + assertTrue(registry.resolve().isEmpty()); + } + + @DisplayName("Resolved rule list is immutable") + @Test + void resolvedRuleListIsImmutable() { + DefaultRecognitionEligibilityRegistry registry = new DefaultRecognitionEligibilityRegistry(); + registry.register(rule("rule-1", 10)); + + assertThrows(UnsupportedOperationException.class, () -> registry.resolve().add(rule("rule-2", 20))); + } + + private @NotNull RecognitionEligibilityRule rule(@NotNull String id, int order) { + return new RecognitionEligibilityRule() { + @Override + public @NotNull String id() { + return id; + } + + @Override + public int order() { + return order; + } + + @Override + public @NotNull RecognitionEligibilityRuleDecision evaluate(@NotNull RecognitionEligibilityContext context) { + return RecognitionEligibilityRuleDecision.abstain(); + } + }; + } +} diff --git a/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/DefaultRecognitionEligibilityServiceTest.java b/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/DefaultRecognitionEligibilityServiceTest.java new file mode 100644 index 000000000..27f68a14b --- /dev/null +++ b/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/DefaultRecognitionEligibilityServiceTest.java @@ -0,0 +1,104 @@ +package me.whereareiam.identica.common.identity.session.recognition.eligibility; + +import me.whereareiam.identica.identity.session.recognition.eligibility.RecognitionEligibilityRule; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityContext; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityDecision; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityRuleDecision; +import me.whereareiam.identica.type.session.recognition.RecognitionAttemptKind; +import me.whereareiam.identica.type.session.recognition.RecognitionTrigger; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Default Recognition Eligibility Service") +class DefaultRecognitionEligibilityServiceTest { + @DisplayName("All abstain leaves recognition eligible") + @Test + void allAbstainLeavesRecognitionEligible() { + DefaultRecognitionEligibilityRegistry registry = new DefaultRecognitionEligibilityRegistry(); + registry.register(rule("a", 10, RecognitionEligibilityRuleDecision.abstain(), true)); + + RecognitionEligibilityDecision decision = new DefaultRecognitionEligibilityService(registry).evaluate(context()); + + assertTrue(decision.isAllowed()); + assertEquals("no-rule-blocked", decision.getReason()); + assertNull(decision.getRuleId()); + } + + @DisplayName("Later block overrides earlier allow") + @Test + void laterBlockOverridesEarlierAllow() { + DefaultRecognitionEligibilityRegistry registry = new DefaultRecognitionEligibilityRegistry(); + registry.register(rule("allow", 10, RecognitionEligibilityRuleDecision.allow("allowed"), true)); + registry.register(rule("block", 20, RecognitionEligibilityRuleDecision.block("blocked"), true)); + + RecognitionEligibilityDecision decision = new DefaultRecognitionEligibilityService(registry).evaluate(context()); + + assertFalse(decision.isAllowed()); + assertEquals("blocked", decision.getReason()); + assertEquals("block", decision.getRuleId()); + } + + @DisplayName("Supports false skips a rule") + @Test + void supportsFalseSkipsRule() { + DefaultRecognitionEligibilityRegistry registry = new DefaultRecognitionEligibilityRegistry(); + registry.register(rule("block", 10, RecognitionEligibilityRuleDecision.block("blocked"), false)); + + RecognitionEligibilityDecision decision = new DefaultRecognitionEligibilityService(registry).evaluate(context()); + + assertTrue(decision.isAllowed()); + } + + @DisplayName("Order ties are resolved by normalized id") + @Test + void orderTiesAreResolvedByNormalizedId() { + DefaultRecognitionEligibilityRegistry registry = new DefaultRecognitionEligibilityRegistry(); + registry.register(rule("z-rule", 10, RecognitionEligibilityRuleDecision.allow("z"), true)); + registry.register(rule("a-rule", 10, RecognitionEligibilityRuleDecision.block("a"), true)); + + RecognitionEligibilityDecision decision = new DefaultRecognitionEligibilityService(registry).evaluate(context()); + + assertFalse(decision.isAllowed()); + assertEquals("a-rule", decision.getRuleId()); + } + + private @NotNull RecognitionEligibilityContext context() { + return RecognitionEligibilityContext.builder() + .providerId("premium") + .attemptKind(RecognitionAttemptKind.SESSION_RECOGNITION) + .trigger(RecognitionTrigger.AUTOMATIC) + .build(); + } + + private @NotNull RecognitionEligibilityRule rule( + @NotNull String id, + int order, + @NotNull RecognitionEligibilityRuleDecision decision, + boolean supported + ) { + return new RecognitionEligibilityRule() { + @Override + public @NotNull String id() { + return id; + } + + @Override + public int order() { + return order; + } + + @Override + public boolean supports(@NotNull RecognitionEligibilityContext context) { + return supported; + } + + @Override + public @NotNull RecognitionEligibilityRuleDecision evaluate(@NotNull RecognitionEligibilityContext context) { + return decision; + } + }; + } +} diff --git a/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/ExplicitSelectionRecognitionEligibilityRuleTest.java b/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/ExplicitSelectionRecognitionEligibilityRuleTest.java new file mode 100644 index 000000000..e52b4d381 --- /dev/null +++ b/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/ExplicitSelectionRecognitionEligibilityRuleTest.java @@ -0,0 +1,45 @@ +package me.whereareiam.identica.common.identity.session.recognition.eligibility.rule; + +import me.whereareiam.identica.model.provider.ProviderContext; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityContext; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityRuleDecision; +import me.whereareiam.identica.type.provider.ProviderOrigin; +import me.whereareiam.identica.type.session.recognition.RecognitionAttemptKind; +import me.whereareiam.identica.type.session.recognition.RecognitionTrigger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@DisplayName("Explicit Selection Recognition Eligibility Rule") +class ExplicitSelectionRecognitionEligibilityRuleTest { + @DisplayName("Allows explicit manual provider selection") + @Test + void allowsExplicitManualProviderSelection() { + RecognitionEligibilityRuleDecision decision = new ExplicitSelectionRecognitionEligibilityRule().evaluate( + RecognitionEligibilityContext.builder() + .providerId("premium") + .selectedProvider(ProviderContext.of("premium", null, "PlayerOne", ProviderOrigin.MANUAL)) + .attemptKind(RecognitionAttemptKind.PROVIDER_HANDSHAKE_RECOGNITION) + .trigger(RecognitionTrigger.EXPLICIT_PROVIDER_SELECTION) + .build() + ); + + assertEquals(RecognitionEligibilityRuleDecision.Status.ALLOW, decision.getStatus()); + } + + @DisplayName("Abstains for automatic provider selection") + @Test + void abstainsForAutomaticProviderSelection() { + RecognitionEligibilityRuleDecision decision = new ExplicitSelectionRecognitionEligibilityRule().evaluate( + RecognitionEligibilityContext.builder() + .providerId("premium") + .selectedProvider(ProviderContext.of("premium", null, "PlayerOne", ProviderOrigin.AUTO)) + .attemptKind(RecognitionAttemptKind.PROVIDER_HANDSHAKE_RECOGNITION) + .trigger(RecognitionTrigger.AUTOMATIC) + .build() + ); + + assertEquals(RecognitionEligibilityRuleDecision.Status.ABSTAIN, decision.getStatus()); + } +} diff --git a/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/RecognitionEnabledEligibilityRuleTest.java b/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/RecognitionEnabledEligibilityRuleTest.java new file mode 100644 index 000000000..f3ee54a85 --- /dev/null +++ b/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/RecognitionEnabledEligibilityRuleTest.java @@ -0,0 +1,76 @@ +package me.whereareiam.identica.common.identity.session.recognition.eligibility.rule; + +import me.whereareiam.identica.model.config.Providers; +import me.whereareiam.identica.model.config.Settings; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityContext; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityRuleDecision; +import me.whereareiam.identica.type.session.recognition.RecognitionAttemptKind; +import me.whereareiam.identica.type.session.recognition.RecognitionTrigger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@DisplayName("Recognition Enabled Eligibility Rule") +class RecognitionEnabledEligibilityRuleTest { + @DisplayName("Blocks session recognition when recognition is disabled") + @Test + void blocksSessionRecognitionWhenRecognitionIsDisabled() { + Settings settings = settings(false); + Providers providers = providers(null); + + RecognitionEligibilityRuleDecision decision = new RecognitionEnabledEligibilityRule(() -> settings, () -> providers) + .evaluate(context(RecognitionAttemptKind.SESSION_RECOGNITION)); + + assertEquals(RecognitionEligibilityRuleDecision.Status.BLOCK, decision.getStatus()); + } + + @DisplayName("Provider override restores recognition when enabled") + @Test + void providerOverrideRestoresRecognitionWhenEnabled() { + Settings settings = settings(false); + Providers providers = providers(true); + + RecognitionEligibilityRuleDecision decision = new RecognitionEnabledEligibilityRule(() -> settings, () -> providers) + .evaluate(context(RecognitionAttemptKind.SESSION_RECOGNITION)); + + assertEquals(RecognitionEligibilityRuleDecision.Status.ABSTAIN, decision.getStatus()); + } + + private RecognitionEligibilityContext context(RecognitionAttemptKind attemptKind) { + return RecognitionEligibilityContext.builder() + .providerId("premium") + .attemptKind(attemptKind) + .trigger(RecognitionTrigger.AUTOMATIC) + .build(); + } + + private Settings settings(boolean enabled) { + Settings.Sessions.Recognition recognition = new Settings.Sessions.Recognition(); + recognition.setEnabled(enabled); + recognition.setValidity(Duration.ofHours(12)); + + Settings.Sessions sessions = new Settings.Sessions(); + sessions.setRecognition(recognition); + + Settings.Connection connection = new Settings.Connection(); + connection.setSessions(sessions); + + Settings settings = new Settings(); + settings.setConnection(connection); + return settings; + } + + private Providers providers(Boolean enabledOverride) { + Providers.ProviderEntry provider = new Providers.ProviderEntry(); + provider.setId("premium"); + provider.getOverrides().getRecognition().setEnabled(enabledOverride); + + Providers providers = new Providers(); + providers.setProviders(List.of(provider)); + return providers; + } +} diff --git a/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/UntrustedIpRecognitionEligibilityRuleTest.java b/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/UntrustedIpRecognitionEligibilityRuleTest.java new file mode 100644 index 000000000..459e26639 --- /dev/null +++ b/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/eligibility/rule/UntrustedIpRecognitionEligibilityRuleTest.java @@ -0,0 +1,92 @@ +package me.whereareiam.identica.common.identity.session.recognition.eligibility.rule; + +import me.whereareiam.identica.model.config.Providers; +import me.whereareiam.identica.model.config.Settings; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityContext; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityRuleDecision; +import me.whereareiam.identica.type.session.recognition.RecognitionAttemptKind; +import me.whereareiam.identica.type.session.recognition.RecognitionTrigger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Untrusted IP Recognition Eligibility Rule") +class UntrustedIpRecognitionEligibilityRuleTest { + @DisplayName("Matches exact IPv4 entries") + @Test + void matchesExactIpv4Entries() { + RecognitionEligibilityRuleDecision decision = rule(settings(true, List.of("203.0.113.10")), providers(false)) + .evaluate(context("203.0.113.10")); + + assertEquals(RecognitionEligibilityRuleDecision.Status.BLOCK, decision.getStatus()); + } + + @DisplayName("Provider override allows recognition on untrusted IPs") + @Test + void providerOverrideAllowsRecognitionOnUntrustedIps() { + RecognitionEligibilityRuleDecision decision = rule(settings(true, List.of("203.0.113.10")), providers(true)) + .evaluate(context("203.0.113.10")); + + assertEquals(RecognitionEligibilityRuleDecision.Status.ALLOW, decision.getStatus()); + } + + @DisplayName("Invalid entries fail with the eligibility config path") + @Test + void invalidEntriesFailWithEligibilityConfigPath() { + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> rule(settings(true, List.of("not-an-ip")), providers(false)).evaluate(context("203.0.113.10")) + ); + + assertTrue(exception.getMessage().contains("settings.connection.sessions.recognition.eligibility.untrustedIps.entries[0]")); + } + + private UntrustedIpRecognitionEligibilityRule rule(Settings settings, Providers providers) { + return new UntrustedIpRecognitionEligibilityRule(() -> settings, () -> providers); + } + + private RecognitionEligibilityContext context(String ip) { + return RecognitionEligibilityContext.builder() + .providerId("premium") + .clientIp(ip) + .attemptKind(RecognitionAttemptKind.SESSION_RECOGNITION) + .trigger(RecognitionTrigger.AUTOMATIC) + .build(); + } + + private Settings settings(boolean enabled, List entries) { + Settings.Sessions.Recognition.Eligibility.UntrustedIps untrustedIps = + new Settings.Sessions.Recognition.Eligibility.UntrustedIps(); + untrustedIps.setEnabled(enabled); + untrustedIps.setEntries(entries); + + Settings.Sessions.Recognition.Eligibility eligibility = new Settings.Sessions.Recognition.Eligibility(); + eligibility.setUntrustedIps(untrustedIps); + + Settings.Sessions.Recognition recognition = new Settings.Sessions.Recognition(); + recognition.setEligibility(eligibility); + + Settings.Sessions sessions = new Settings.Sessions(); + sessions.setRecognition(recognition); + + Settings.Connection connection = new Settings.Connection(); + connection.setSessions(sessions); + + Settings settings = new Settings(); + settings.setConnection(connection); + return settings; + } + + private Providers providers(boolean allowOnUntrustedIp) { + Providers.ProviderEntry premium = new Providers.ProviderEntry(); + premium.setId("premium"); + premium.getOverrides().getRecognition().getEligibility().setAllowOnUntrustedIp(allowOnUntrustedIp); + + Providers providers = new Providers(); + providers.setProviders(List.of(premium)); + return providers; + } +} diff --git a/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/policy/UntrustedIpRecognitionGuardTest.java b/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/policy/UntrustedIpRecognitionGuardTest.java deleted file mode 100644 index 542d7a250..000000000 --- a/identica-common/src/test/java/me/whereareiam/identica/common/identity/session/recognition/policy/UntrustedIpRecognitionGuardTest.java +++ /dev/null @@ -1,144 +0,0 @@ -package me.whereareiam.identica.common.identity.session.recognition.policy; - -import me.whereareiam.identica.identity.session.recognition.policy.UntrustedIpRecognitionDecision.Outcome; -import me.whereareiam.identica.model.config.Providers; -import me.whereareiam.identica.model.config.Settings; -import me.whereareiam.identica.model.provider.ProviderContext; -import me.whereareiam.identica.type.provider.ProviderOrigin; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - -@DisplayName("Untrusted IP Recognition Guard") -class UntrustedIpRecognitionGuardTest { - @DisplayName("Matches an exact IPv4 entry") - @Test - void matchesExactIpv4Entry() { - assertTrue(guard(settings(true, List.of("203.0.113.10")), providers()) - .isUntrustedIp("203.0.113.10")); - } - - @DisplayName("Matches an IPv4 CIDR entry") - @Test - void matchesIpv4CidrEntry() { - assertTrue(guard(settings(true, List.of("203.0.113.0/24")), providers()) - .isUntrustedIp("203.0.113.77")); - } - - @DisplayName("Matches an IPv6 CIDR entry") - @Test - void matchesIpv6CidrEntry() { - assertTrue(guard(settings(true, List.of("2001:db8::/32")), providers()) - .isUntrustedIp("2001:db8::42")); - } - - @DisplayName("Does not block when the feature is disabled") - @Test - void disabledGuardDoesNotBlockRecognition() { - assertFalse(guard(settings(false, List.of("203.0.113.10")), providers()) - .evaluateAutomaticRecognition("premium", "203.0.113.10", null) - .isBlocked()); - } - - @DisplayName("Does not block when the client IP is missing") - @Test - void missingIpDoesNotBlockRecognition() { - assertFalse(guard(settings(true, List.of("203.0.113.10")), providers()) - .evaluateAutomaticRecognition("premium", null, null) - .isBlocked()); - } - - @DisplayName("Fails clearly when a configured entry is malformed") - @Test - void invalidEntryFailsClearly() { - IllegalStateException exception = assertThrows( - IllegalStateException.class, - () -> guard(settings(true, List.of("not-an-ip")), providers()).isUntrustedIp("203.0.113.10") - ); - - assertTrue(exception.getMessage().contains("settings.connection.sessions.recognition.untrustedIps.entries[0]")); - } - - @DisplayName("Provider override restores automatic recognition") - @Test - void providerOverrideRestoresRecognition() { - Providers providers = providers(); - provider(providers).getOverrides().setAllowRecognitionOnUntrustedIp(true); - - assertSame(Outcome.ALLOWED_PROVIDER_OVERRIDE, guard(settings(true, List.of("203.0.113.10")), providers) - .evaluateAutomaticRecognition("premium", "203.0.113.10", null) - .getOutcome()); - } - - @DisplayName("Explicit entrypoint selection bypasses blocking") - @Test - void explicitEntrypointSelectionBypassesBlocking() { - assertSame(Outcome.ALLOWED_EXPLICIT_SELECTION, guard(settings(true, List.of("203.0.113.10")), providers()) - .evaluateAutomaticRecognition( - "premium", - "203.0.113.10", - ProviderContext.of("premium", null, "PlayerOne", ProviderOrigin.ENTRYPOINT) - ).getOutcome()); - } - - @DisplayName("Explicit manual selection bypasses blocking") - @Test - void explicitManualSelectionBypassesBlocking() { - assertSame(Outcome.ALLOWED_EXPLICIT_SELECTION, guard(settings(true, List.of("203.0.113.10")), providers()) - .evaluateAutomaticRecognition( - "premium", - "203.0.113.10", - ProviderContext.of("premium", null, "PlayerOne", ProviderOrigin.MANUAL) - ).getOutcome()); - } - - @DisplayName("Valid configuration can be resolved eagerly without throwing") - @Test - void validConfigurationResolvesEagerly() { - assertDoesNotThrow(() -> guard(settings(true, List.of("203.0.113.0/24")), providers()) - .isUntrustedIp("203.0.113.42")); - } - - private UntrustedIpRecognitionGuard guard(Settings settings, Providers providers) { - return new UntrustedIpRecognitionGuard(() -> settings, () -> providers); - } - - private Settings settings(boolean enabled, List entries) { - Settings.Sessions.Recognition.UntrustedIps untrustedIps = new Settings.Sessions.Recognition.UntrustedIps(); - untrustedIps.setEnabled(enabled); - untrustedIps.setEntries(entries); - - Settings.Sessions.Recognition recognition = new Settings.Sessions.Recognition(); - recognition.setUntrustedIps(untrustedIps); - - Settings.Sessions sessions = new Settings.Sessions(); - sessions.setRecognition(recognition); - - Settings.Connection connection = new Settings.Connection(); - connection.setSessions(sessions); - - Settings settings = new Settings(); - settings.setConnection(connection); - return settings; - } - - private Providers providers() { - Providers providers = new Providers(); - Providers.ProviderEntry premium = new Providers.ProviderEntry(); - premium.setId("premium"); - Providers.ProviderEntry credential = new Providers.ProviderEntry(); - credential.setId("credential"); - providers.setProviders(List.of(premium, credential)); - return providers; - } - - private Providers.ProviderEntry provider(Providers providers) { - return providers.getProviders().stream() - .filter(entry -> entry.getId().equals("premium")) - .findFirst() - .orElseThrow(); - } -} diff --git a/identica-engine/src/main/java/me/whereareiam/identica/engine/pipeline/prepare/group/handshake/phase/EvaluateHandshakePhase.java b/identica-engine/src/main/java/me/whereareiam/identica/engine/pipeline/prepare/group/handshake/phase/EvaluateHandshakePhase.java index a6dc466d8..dda3e8c3d 100644 --- a/identica-engine/src/main/java/me/whereareiam/identica/engine/pipeline/prepare/group/handshake/phase/EvaluateHandshakePhase.java +++ b/identica-engine/src/main/java/me/whereareiam/identica/engine/pipeline/prepare/group/handshake/phase/EvaluateHandshakePhase.java @@ -7,8 +7,7 @@ import me.whereareiam.identica.handshake.HandshakeStore; import me.whereareiam.identica.handshake.policy.HandshakePolicy; import me.whereareiam.identica.handshake.policy.ProviderScopedHandshakePolicy; -import me.whereareiam.identica.identity.session.recognition.policy.UntrustedIpRecognitionDecision; -import me.whereareiam.identica.identity.session.recognition.policy.UntrustedIpRecognitionPolicy; +import me.whereareiam.identica.identity.session.recognition.eligibility.RecognitionEligibilityService; import me.whereareiam.identica.logging.Logger; import me.whereareiam.identica.model.auth.handshake.HandshakeDecision; import me.whereareiam.identica.model.auth.handshake.HandshakeRequest; @@ -17,7 +16,12 @@ import me.whereareiam.identica.model.pipeline.prepare.decision.PrepareDecisionItem; import me.whereareiam.identica.model.pipeline.state.PipelineState; import me.whereareiam.identica.model.provider.ProviderContext; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityContext; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityDecision; import me.whereareiam.identica.pipeline.PipelinePhase; +import me.whereareiam.identica.type.provider.ProviderOrigin; +import me.whereareiam.identica.type.session.recognition.RecognitionAttemptKind; +import me.whereareiam.identica.type.session.recognition.RecognitionTrigger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -28,7 +32,7 @@ @RequiredArgsConstructor(onConstructor_ = @Inject) public class EvaluateHandshakePhase implements PipelinePhase { private final HandshakeStore handshakeStore; - private final UntrustedIpRecognitionPolicy untrustedIpRecognitionPolicy; + private final RecognitionEligibilityService recognitionEligibilityService; @Override public @NotNull String id() { @@ -73,7 +77,8 @@ public boolean supports(@NotNull PipelineState pipelineState, @NotNull PrepareGr HandshakeDecision decision = HandshakeDecision.allow(); String clientIp = state.getRequest().getIdentity().getIp(); for (HandshakePolicy policy : handshakeStore.policies()) { - if (!shouldEvaluate(policy, context.getProvider(), clientIp)) continue; + if (!shouldEvaluate(policy, context.getProvider(), clientIp, state.getRequest().getIdentity().getOrigin())) + continue; decision = merge(decision, evaluatePolicy(policy, request)); } @@ -85,27 +90,45 @@ public boolean supports(@NotNull PipelineState pipelineState, @NotNull PrepareGr private boolean shouldEvaluate( @NotNull HandshakePolicy policy, @Nullable ProviderContext provider, - @Nullable String clientIp + @Nullable String clientIp, + @Nullable me.whereareiam.identica.identity.actor.ConnectionIdentity.Origin origin ) { if (!(policy instanceof ProviderScopedHandshakePolicy scoped)) return true; if (provider == null || provider.getProviderId() == null || provider.getProviderId().isBlank()) return true; - UntrustedIpRecognitionDecision decision = untrustedIpRecognitionPolicy.evaluateAutomaticRecognition( - scoped.providerId(), - clientIp, - provider - ); - if (decision.isBlocked()) { + if (!scoped.providerId().equalsIgnoreCase(provider.getProviderId())) + return false; + + RecognitionEligibilityDecision decision = recognitionEligibilityService.evaluate(RecognitionEligibilityContext.builder() + .providerId(scoped.providerId()) + .providerUsername(provider.getProviderUsername()) + .clientIp(clientIp) + .selectedProvider(provider) + .origin(origin) + .attemptKind(RecognitionAttemptKind.PROVIDER_HANDSHAKE_RECOGNITION) + .trigger(resolveTrigger(provider)) + .build()); + + if (!decision.isAllowed()) { Logger.debug( - "Skipping provider-scoped handshake recognition provider=%s username=%s ip=%s outcome=%s", + "Skipping provider-scoped handshake recognition provider=%s username=%s ip=%s rule=%s reason=%s", scoped.providerId(), provider.getProviderUsername(), clientIp, - decision.getOutcome() + decision.getRuleId(), + decision.getReason() ); return false; } - return scoped.providerId().equalsIgnoreCase(provider.getProviderId()); + return true; + } + + private @NotNull RecognitionTrigger resolveTrigger(@NotNull ProviderContext provider) { + ProviderOrigin source = provider.getSource(); + if (source == ProviderOrigin.ENTRYPOINT || source == ProviderOrigin.MANUAL) + return RecognitionTrigger.EXPLICIT_PROVIDER_SELECTION; + + return RecognitionTrigger.AUTOMATIC; } private @NotNull HandshakeDecision evaluatePolicy( diff --git a/identica-engine/src/test/java/me/whereareiam/identica/engine/prepare/PreparePipelineTest.java b/identica-engine/src/test/java/me/whereareiam/identica/engine/prepare/PreparePipelineTest.java index 16045c281..447f97ab8 100644 --- a/identica-engine/src/test/java/me/whereareiam/identica/engine/prepare/PreparePipelineTest.java +++ b/identica-engine/src/test/java/me/whereareiam/identica/engine/prepare/PreparePipelineTest.java @@ -31,8 +31,7 @@ import me.whereareiam.identica.handshake.policy.ProviderScopedHandshakePolicy; import me.whereareiam.identica.identity.account.RegistrationAccountService; import me.whereareiam.identica.identity.actor.ConnectionIdentity; -import me.whereareiam.identica.identity.session.recognition.policy.UntrustedIpRecognitionDecision; -import me.whereareiam.identica.identity.session.recognition.policy.UntrustedIpRecognitionPolicy; +import me.whereareiam.identica.identity.session.recognition.eligibility.RecognitionEligibilityService; import me.whereareiam.identica.model.auth.handshake.HandshakeDecision; import me.whereareiam.identica.model.auth.handshake.HandshakeRequest; import me.whereareiam.identica.model.config.Messages; @@ -48,6 +47,7 @@ import me.whereareiam.identica.model.pipeline.state.PipelineStateReference; import me.whereareiam.identica.model.provider.ProviderContext; import me.whereareiam.identica.model.provider.ResolvedEntrypoint; +import me.whereareiam.identica.model.session.recognition.eligibility.RecognitionEligibilityDecision; import me.whereareiam.identica.pipeline.prepare.PrepareStateStore; import me.whereareiam.identica.pipeline.state.PipelineStateStore; import me.whereareiam.identica.provider.ProviderOperations; @@ -71,7 +71,8 @@ import java.util.UUID; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -94,7 +95,7 @@ class PreparePipelineTest { @Mock private PipelineStateStore pipelineStateStore; @Mock - private UntrustedIpRecognitionPolicy untrustedIpRecognitionPolicy; + private RecognitionEligibilityService recognitionEligibilityService; @Mock private DeliveryService deliveryService; @@ -248,8 +249,6 @@ void handshakeStageSkipsScopedPoliciesForOtherProviders() { ), 1_000L); CountingScopedHandshakePolicy premiumPolicy = new CountingScopedHandshakePolicy("premium"); - when(untrustedIpRecognitionPolicy.evaluateAutomaticRecognition(eq("premium"), eq("127.0.0.1"), any())) - .thenReturn(allowedDecision()); when(handshakeStore.policies()).thenReturn(java.util.Set.of(premiumPolicy)); when(pipelineStateStore.find(argThat((PipelineStateReference reference) -> connectionKey.equals(reference.getConnectionKey())))) .thenReturn(Optional.of(pendingMigrationState)); @@ -293,7 +292,7 @@ void handshakeStageSkipsScopedPoliciesOnBlockedUntrustedIp() { ), 1_000L); CountingScopedHandshakePolicy premiumPolicy = new CountingScopedHandshakePolicy("premium"); - when(untrustedIpRecognitionPolicy.evaluateAutomaticRecognition(eq("premium"), eq("127.0.0.1"), any())) + when(recognitionEligibilityService.evaluate(any())) .thenReturn(blockedDecision()); when(handshakeStore.policies()).thenReturn(java.util.Set.of(premiumPolicy)); when(pipelineStateStore.find(argThat((PipelineStateReference reference) -> connectionKey.equals(reference.getConnectionKey())))) @@ -338,7 +337,7 @@ void premiumAutoRecognitionHandshakePathIsSkippedOnBlockedUntrustedIp() { ), 1_000L); CountingScopedHandshakePolicy premiumPolicy = new CountingScopedHandshakePolicy("premium"); - when(untrustedIpRecognitionPolicy.evaluateAutomaticRecognition(eq("premium"), eq("127.0.0.1"), any())) + when(recognitionEligibilityService.evaluate(any())) .thenReturn(blockedDecision()); when(handshakeStore.policies()).thenReturn(java.util.Set.of(premiumPolicy)); when(pipelineStateStore.find(argThat((PipelineStateReference reference) -> connectionKey.equals(reference.getConnectionKey())))) @@ -383,7 +382,7 @@ void handshakeStageRunsScopedPolicyWhenOverrideAllowsRecognition() { ), 1_000L); CountingScopedHandshakePolicy premiumPolicy = new CountingScopedHandshakePolicy("premium"); - when(untrustedIpRecognitionPolicy.evaluateAutomaticRecognition(eq("premium"), eq("127.0.0.1"), any())) + when(recognitionEligibilityService.evaluate(any())) .thenReturn(allowedDecision()); when(handshakeStore.policies()).thenReturn(java.util.Set.of(premiumPolicy)); when(pipelineStateStore.find(argThat((PipelineStateReference reference) -> connectionKey.equals(reference.getConnectionKey())))) @@ -428,7 +427,7 @@ void handshakeStageRunsForExplicitProviderSelectionOnUntrustedIp() { ), 1_000L); CountingScopedHandshakePolicy premiumPolicy = new CountingScopedHandshakePolicy("premium"); - when(untrustedIpRecognitionPolicy.evaluateAutomaticRecognition(eq("premium"), eq("127.0.0.1"), any())) + when(recognitionEligibilityService.evaluate(any())) .thenReturn(allowedDecision()); when(handshakeStore.policies()).thenReturn(java.util.Set.of(premiumPolicy)); when(pipelineStateStore.find(argThat((PipelineStateReference reference) -> connectionKey.equals(reference.getConnectionKey())))) @@ -474,7 +473,7 @@ void handshakeStageKeepsNonProviderScopedPoliciesWhenScopedRecognitionBlocked() CountingScopedHandshakePolicy premiumPolicy = new CountingScopedHandshakePolicy("premium"); CountingHandshakePolicy globalPolicy = new CountingHandshakePolicy(); - when(untrustedIpRecognitionPolicy.evaluateAutomaticRecognition(eq("premium"), eq("127.0.0.1"), any())) + when(recognitionEligibilityService.evaluate(any())) .thenReturn(blockedDecision()); when(handshakeStore.policies()).thenReturn(java.util.Set.of(premiumPolicy, globalPolicy)); when(pipelineStateStore.find(argThat((PipelineStateReference reference) -> connectionKey.equals(reference.getConnectionKey())))) @@ -749,7 +748,7 @@ void profileStageClearsPendingMigrationWhenObservedProviderDiffers() { new RestorePrepareStatePhase(prepareStateStore), new ResolveEntrypointPhase(providerOperations, contextResolver), new ResolvePendingMigrationContextPhase(pipelineStateStore), - new EvaluateHandshakePhase(handshakeStore, untrustedIpRecognitionPolicy), + new EvaluateHandshakePhase(handshakeStore, recognitionEligibilityService), new FinalizeHandshakePhase(eventManager, Messages::new), new ResolveProfilePhase(providerOperations, contextResolver), new ResolvePendingMigrationAccountPhase( @@ -783,16 +782,20 @@ void profileStageClearsPendingMigrationWhenObservedProviderDiffers() { return identity; } - private UntrustedIpRecognitionDecision allowedDecision() { - return new UntrustedIpRecognitionDecision( - UntrustedIpRecognitionDecision.Outcome.ALLOWED_IP_NOT_MATCHED - ); + private RecognitionEligibilityDecision allowedDecision() { + return RecognitionEligibilityDecision.builder() + .allowed(true) + .reason("allowed") + .ruleId("test-allow") + .build(); } - private UntrustedIpRecognitionDecision blockedDecision() { - return new UntrustedIpRecognitionDecision( - UntrustedIpRecognitionDecision.Outcome.BLOCKED_UNTRUSTED_IP - ); + private RecognitionEligibilityDecision blockedDecision() { + return RecognitionEligibilityDecision.builder() + .allowed(false) + .reason("blocked") + .ruleId("test-block") + .build(); } private static final class TestPrepareStateStore implements PrepareStateStore {