XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEServerManager

【Java】我的世界XFE服务器管理器

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/XFEServerManager

feat: expand trigger economy and harden authentication

6fa3c00
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

24 个文件 +456 -277
Modified common/core/src/main/java/com/xfestudio/xfeservermanager/core/trigger/TriggerActionTree.java +4 -21
@@ -8,18 +8,15 @@ import java.util.Objects;
8 8 /** Validation and depth-first traversal for conditional trigger action trees. */
9 9 public final class TriggerActionTree {
10 10 public static final String CONDITION_TYPE = "condition";
11 public static final int MAX_CONDITION_DEPTH = 8;
12 11
13 12 private TriggerActionTree() { }
14 13
15 14 /**
16 * Validates the structural limits shared by visual documents and XFE Script.
17 * The action limit counts both executable actions and structural condition nodes.
15 * Validates the recursive action shape shared by visual documents and XFE Script.
18 16 */
19 17 public static void validate(List<TriggerDefinition.Action> actions) {
20 18 Objects.requireNonNull(actions, "actions");
21 Counter counter = new Counter();
22 validate(actions, 0, counter);
19 validateNodes(actions);
23 20 }
24 21
25 22 /** Returns every non-structural action in stable depth-first document order. */
@@ -79,14 +76,9 @@ public final class TriggerActionTree {
79 76 }
80 77 }
81 78
82 private static void validate(
83 List<TriggerDefinition.Action> actions, int conditionDepth, Counter counter) {
79 private static void validateNodes(List<TriggerDefinition.Action> actions) {
84 80 for (TriggerDefinition.Action action : actions) {
85 81 if (action == null) throw new IllegalArgumentException("action must not be null");
86 counter.nodes++;
87 if (counter.nodes > TriggerDefinition.MAX_ACTIONS) {
88 throw new IllegalArgumentException("a trigger cannot contain more than 32 actions");
89 }
90 82 if (!CONDITION_TYPE.equals(action.type())) {
91 83 if (!action.children().isEmpty()) {
92 84 throw new IllegalArgumentException(
@@ -95,16 +87,11 @@ public final class TriggerActionTree {
95 87 continue;
96 88 }
97 89
98 int nestedDepth = conditionDepth + 1;
99 if (nestedDepth > MAX_CONDITION_DEPTH) {
100 throw new IllegalArgumentException(
101 "trigger action conditions cannot be nested more than 8 levels");
102 }
103 90 condition(action);
104 91 if (action.children().isEmpty()) {
105 92 throw new IllegalArgumentException("a condition action needs at least one child action");
106 93 }
107 validate(action.children(), nestedDepth, counter);
94 validateNodes(action.children());
108 95 }
109 96 }
110 97
@@ -142,8 +129,4 @@ public final class TriggerActionTree {
142 129 }
143 130 }
144 131 }
145
146 private static final class Counter {
147 private int nodes;
148 }
149 132 }
Modified common/core/src/main/java/com/xfestudio/xfeservermanager/core/trigger/TriggerCatalog.java +32 -5
@@ -30,7 +30,9 @@ public final class TriggerCatalog {
30 30 "set_gamemode", "add_effect", "remove_effects", "heal", "feed", "set_time",
31 31 "set_weather", "whitelist_add", "whitelist_remove", "ban", "pardon", "log",
32 32 "variable", "wait", "run_trigger", "open_menu", "close_menu",
33 "economy_deposit", "economy_withdraw", "economy_set_balance", "economy_transfer");
33 "economy_deposit", "economy_withdraw", "economy_set_balance", "economy_transfer",
34 "economy_deposit_player", "economy_withdraw_player", "economy_set_player_balance",
35 "economy_transfer_players");
34 36 public static final Map<String, List<String>> ACTION_PARAMETERS = Map.ofEntries(
35 37 Map.entry("send_player", List.of("message")),
36 38 Map.entry("broadcast", List.of("message")),
@@ -63,7 +65,11 @@ public final class TriggerCatalog {
63 65 Map.entry("economy_deposit", List.of("currency", "amount", "reason")),
64 66 Map.entry("economy_withdraw", List.of("currency", "amount", "reason")),
65 67 Map.entry("economy_set_balance", List.of("currency", "amount", "reason")),
66 Map.entry("economy_transfer", List.of("currency", "amount", "target", "reason")));
68 Map.entry("economy_transfer", List.of("currency", "amount", "target", "reason")),
69 Map.entry("economy_deposit_player", List.of("player", "currency", "amount", "reason")),
70 Map.entry("economy_withdraw_player", List.of("player", "currency", "amount", "reason")),
71 Map.entry("economy_set_player_balance", List.of("player", "currency", "amount", "reason")),
72 Map.entry("economy_transfer_players", List.of("source", "target", "currency", "amount", "reason")));
67 73 private static final Set<String> EVENT_SET = Set.copyOf(EVENTS);
68 74 private static final Set<String> OPERATOR_SET = Set.copyOf(OPERATORS);
69 75 private static final Set<String> ACTION_SET = Set.copyOf(ACTIONS);
@@ -131,7 +137,9 @@ public final class TriggerCatalog {
131 137 case "wait" -> "mode";
132 138 case "run_trigger" -> "triggerId";
133 139 case "open_menu" -> "menuId";
134 case "economy_deposit", "economy_withdraw", "economy_set_balance", "economy_transfer" -> "currency";
140 case "economy_deposit", "economy_withdraw", "economy_set_balance", "economy_transfer",
141 "economy_deposit_player", "economy_withdraw_player", "economy_set_player_balance",
142 "economy_transfer_players" -> "currency";
135 143 case "clear_inventory", "remove_effects", "heal", "feed", "whitelist_add",
136 144 "whitelist_remove", "ban", "pardon", "close_menu" -> null;
137 145 default -> "message";
@@ -273,7 +281,9 @@ public final class TriggerCatalog {
273 281 }
274 282 }
275 283 }
276 case "economy_deposit", "economy_withdraw", "economy_set_balance", "economy_transfer" -> {
284 case "economy_deposit", "economy_withdraw", "economy_set_balance", "economy_transfer",
285 "economy_deposit_player", "economy_withdraw_player", "economy_set_player_balance",
286 "economy_transfer_players" -> {
277 287 String currency = values.getOrDefault("currency", "");
278 288 if (!TriggerEvaluator.hasTemplateVariable(currency)
279 289 && !currency.matches("(?:[a-z][a-z0-9_]{0,31}|[0-9a-fA-F-]{36})")) {
@@ -283,7 +293,8 @@ public final class TriggerCatalog {
283 293 if (!TriggerEvaluator.hasTemplateVariable(amount)) {
284 294 try {
285 295 java.math.BigDecimal parsed = new java.math.BigDecimal(amount);
286 if (!action.type().equals("economy_set_balance") && parsed.signum() <= 0) {
296 if (!Set.of("economy_set_balance", "economy_set_player_balance").contains(action.type())
297 && parsed.signum() <= 0) {
287 298 throw new NumberFormatException();
288 299 }
289 300 } catch (RuntimeException invalid) {
@@ -298,6 +309,14 @@ public final class TriggerCatalog {
298 309 && values.getOrDefault("target", "").isBlank()) {
299 310 throw new IllegalArgumentException("economy_transfer requires target");
300 311 }
312 if (Set.of("economy_deposit_player", "economy_withdraw_player",
313 "economy_set_player_balance").contains(action.type())) {
314 economyIdentity(values.get("player"), "player");
315 }
316 if (action.type().equals("economy_transfer_players")) {
317 economyIdentity(values.get("source"), "source");
318 economyIdentity(values.get("target"), "target");
319 }
301 320 }
302 321 default -> { }
303 322 }
@@ -310,6 +329,14 @@ public final class TriggerCatalog {
310 329 }
311 330 }
312 331
332 private static void economyIdentity(String value, String name) {
333 if (value == null || value.isBlank() || value.length() > 128
334 || value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0) {
335 throw new IllegalArgumentException("economy " + name
336 + " must be a player name, UUID, or template variable");
337 }
338 }
339
313 340 private static void enumeration(String value, String name, Set<String> accepted) {
314 341 if (TriggerEvaluator.hasTemplateVariable(value)) return;
315 342 if (value == null || !accepted.contains(value.strip().toLowerCase(java.util.Locale.ROOT))) {
Modified common/core/src/main/java/com/xfestudio/xfeservermanager/core/trigger/TriggerCommandConfiguration.java +4 -24
@@ -13,9 +13,6 @@ import java.util.UUID;
13 13 public final class TriggerCommandConfiguration {
14 14 public static final String EVENT_TYPE = "player.command_trigger";
15 15 public static final int MAX_NAME_LENGTH = 32;
16 public static final int MAX_ARGUMENTS = 16;
17 public static final int MAX_ARGUMENT_NODES = 64;
18 public static final int MAX_ARGUMENT_DEPTH = 8;
19 16 private static final Set<String> PARAMETERS = Set.of("command", "arguments");
20 17 private static final String NAME_PATTERN = "[a-z][a-z0-9_-]{0," + (MAX_NAME_LENGTH - 1) + "}";
21 18
@@ -39,9 +36,6 @@ public final class TriggerCommandConfiguration {
39 36 }
40 37
41 38 String rawArguments = event.configuration().getOrDefault("arguments", "").strip();
42 if (rawArguments.length() > MAX_ARGUMENTS * (MAX_NAME_LENGTH + 1)) {
43 throw new IllegalArgumentException("arguments configuration is too long");
44 }
45 39 if (rawArguments.chars().anyMatch(character -> Character.isWhitespace(character) && character != ' ')) {
46 40 throw new IllegalArgumentException("arguments must be separated by spaces");
47 41 }
@@ -58,10 +52,6 @@ public final class TriggerCommandConfiguration {
58 52 throw new IllegalArgumentException("duplicate command argument: " + argument);
59 53 }
60 54 arguments.add(argument);
61 if (arguments.size() > MAX_ARGUMENTS) {
62 throw new IllegalArgumentException("a trigger command cannot have more than "
63 + MAX_ARGUMENTS + " arguments");
64 }
65 55 }
66 56 }
67 57 if (argumentTree.isEmpty() && !arguments.isEmpty()) {
@@ -103,23 +93,13 @@ public final class TriggerCommandConfiguration {
103 93
104 94 private static void validateArgumentTree(List<TriggerDefinition.CommandArgument> roots) {
105 95 Set<String> names = new LinkedHashSet<>();
106 int[] nodes = { 0 };
107 validateArgumentTree(roots, 1, names, nodes);
96 validateArgumentTree(roots, names);
108 97 }
109 98
110 private static void validateArgumentTree(List<TriggerDefinition.CommandArgument> arguments, int depth,
111 Set<String> names, int[] nodes) {
112 if (depth > MAX_ARGUMENT_DEPTH) {
113 throw new IllegalArgumentException("command arguments cannot be nested more than "
114 + MAX_ARGUMENT_DEPTH + " levels");
115 }
99 private static void validateArgumentTree(List<TriggerDefinition.CommandArgument> arguments,
100 Set<String> names) {
116 101 Set<String> siblingKeys = new LinkedHashSet<>();
117 102 for (TriggerDefinition.CommandArgument argument : arguments) {
118 nodes[0]++;
119 if (nodes[0] > MAX_ARGUMENT_NODES) {
120 throw new IllegalArgumentException("a trigger command cannot have more than "
121 + MAX_ARGUMENT_NODES + " argument nodes");
122 }
123 103 String branchKey = argument.type().equals("literal")
124 104 ? "literal:" + argument.literal() : "argument:" + argument.name();
125 105 if (!siblingKeys.add(branchKey)) {
@@ -133,7 +113,7 @@ public final class TriggerCommandConfiguration {
133 113 throw new IllegalArgumentException(argument.type() + " must be the final argument in its branch");
134 114 }
135 115 if (!argument.children().isEmpty()) {
136 validateArgumentTree(argument.children(), depth + 1, names, nodes);
116 validateArgumentTree(argument.children(), names);
137 117 }
138 118 }
139 119 }
Modified common/core/src/main/java/com/xfestudio/xfeservermanager/core/trigger/TriggerDefinition.java +0 -11
@@ -25,8 +25,6 @@ public record TriggerDefinition(
25 25 Instant updatedAt,
26 26 boolean migrated) {
27 27
28 public static final int MAX_CONDITIONS = 32;
29 public static final int MAX_ACTIONS = 32;
30 28 public static final int MAX_SCRIPT_CHARACTERS = 65_536;
31 29
32 30 public TriggerDefinition {
@@ -43,9 +41,6 @@ public record TriggerDefinition(
43 41 createdBy = bounded(createdBy, "createdBy", 1, 120);
44 42 Objects.requireNonNull(createdAt, "createdAt");
45 43 Objects.requireNonNull(updatedAt, "updatedAt");
46 if (conditions.size() > MAX_CONDITIONS) {
47 throw new IllegalArgumentException("a trigger cannot contain more than 32 conditions");
48 }
49 44 TriggerActionTree.validate(actions);
50 45 if (script.length() > MAX_SCRIPT_CHARACTERS) {
51 46 throw new IllegalArgumentException("script cannot exceed 65536 characters");
@@ -115,8 +110,6 @@ public record TriggerDefinition(
115 110 configuration = Map.copyOf(configuration);
116 111 arguments = checkedList(arguments, "command argument");
117 112 variables = checkedList(variables, "trigger variable");
118 if (arguments.size() > 16) throw new IllegalArgumentException("too many root command arguments");
119 if (variables.size() > 32) throw new IllegalArgumentException("too many trigger variables");
120 113 java.util.LinkedHashSet<String> variableNames = new java.util.LinkedHashSet<>();
121 114 for (VariableDefinition variable : variables) {
122 115 if (!variableNames.add(variable.contextKey())) {
@@ -145,7 +138,6 @@ public record TriggerDefinition(
145 138 .map(value -> bounded(value, "command argument suggestion", 1, 128)).distinct().toList();
146 139 if (suggestions.size() > 64) throw new IllegalArgumentException("too many command argument suggestions");
147 140 children = checkedList(children, "command argument child");
148 if (children.size() > 16) throw new IllegalArgumentException("too many command argument branches");
149 141 TriggerValueTypes.validateCommandLiteral(type, literal, minimum, maximum);
150 142 }
151 143 }
@@ -241,9 +233,6 @@ public record TriggerDefinition(
241 233 Objects.requireNonNull(conditionMode, "conditionMode");
242 234 conditions = checkedList(conditions, "condition");
243 235 actions = checkedList(actions, "action");
244 if (conditions.size() > MAX_CONDITIONS) {
245 throw new IllegalArgumentException("a trigger cannot contain more than 32 conditions");
246 }
247 236 TriggerActionTree.validate(actions);
248 237 }
249 238 }
Modified common/core/src/main/java/com/xfestudio/xfeservermanager/core/trigger/TriggerJsonValueParser.java +9 -20
@@ -6,14 +6,10 @@ import java.util.LinkedHashMap;
6 6 import java.util.List;
7 7 import java.util.Map;
8 8
9 /** Small bounded JSON value parser used by nested trigger-variable containers. */
9 /** Small JSON value parser used by nested trigger-variable containers. */
10 10 final class TriggerJsonValueParser {
11 private static final int MAX_DEPTH = 16;
12 private static final int MAX_ENTRIES = 1_024;
13
14 11 private final String source;
15 12 private int position;
16 private int entries;
17 13
18 14 private TriggerJsonValueParser(String source) {
19 15 this.source = source;
@@ -24,20 +20,19 @@ final class TriggerJsonValueParser {
24 20 throw new IllegalArgumentException("structured variable value is too long");
25 21 }
26 22 TriggerJsonValueParser parser = new TriggerJsonValueParser(source);
27 Object value = parser.value(0);
23 Object value = parser.value();
28 24 parser.whitespace();
29 25 if (parser.position != source.length()) throw parser.invalid("unexpected trailing content");
30 26 return value;
31 27 }
32 28
33 private Object value(int depth) {
34 if (depth > MAX_DEPTH) throw invalid("JSON nesting is too deep");
29 private Object value() {
35 30 whitespace();
36 31 if (position >= source.length()) throw invalid("expected a JSON value");
37 32 return switch (source.charAt(position)) {
38 33 case '"' -> string();
39 case '[' -> array(depth + 1);
40 case '{' -> object(depth + 1);
34 case '[' -> array();
35 case '{' -> object();
41 36 case 't' -> literal("true", Boolean.TRUE);
42 37 case 'f' -> literal("false", Boolean.FALSE);
43 38 case 'n' -> literal("null", null);
@@ -45,27 +40,25 @@ final class TriggerJsonValueParser {
45 40 };
46 41 }
47 42
48 private List<Object> array(int depth) {
43 private List<Object> array() {
49 44 position++;
50 45 List<Object> result = new ArrayList<>();
51 46 whitespace();
52 47 if (consume(']')) return List.of();
53 48 while (true) {
54 entry();
55 result.add(value(depth));
49 result.add(value());
56 50 whitespace();
57 51 if (consume(']')) return Collections.unmodifiableList(new ArrayList<>(result));
58 52 require(',');
59 53 }
60 54 }
61 55
62 private Map<String, Object> object(int depth) {
56 private Map<String, Object> object() {
63 57 position++;
64 58 Map<String, Object> result = new LinkedHashMap<>();
65 59 whitespace();
66 60 if (consume('}')) return Map.of();
67 61 while (true) {
68 entry();
69 62 whitespace();
70 63 if (position >= source.length() || source.charAt(position) != '"') {
71 64 throw invalid("dictionary keys must be JSON strings");
@@ -76,7 +69,7 @@ final class TriggerJsonValueParser {
76 69 if (result.containsKey(key)) {
77 70 throw invalid("duplicate dictionary key " + key);
78 71 }
79 result.put(key, value(depth));
72 result.put(key, value());
80 73 whitespace();
81 74 if (consume('}')) return Collections.unmodifiableMap(new LinkedHashMap<>(result));
82 75 require(',');
@@ -163,10 +156,6 @@ final class TriggerJsonValueParser {
163 156 return value;
164 157 }
165 158
166 private void entry() {
167 if (++entries > MAX_ENTRIES) throw invalid("structured variable has too many entries");
168 }
169
170 159 private void whitespace() {
171 160 while (position < source.length() && Character.isWhitespace(source.charAt(position))) position++;
172 161 }
Modified common/core/src/main/java/com/xfestudio/xfeservermanager/core/trigger/TriggerScriptCompiler.java +0 -19
@@ -37,7 +37,6 @@ public final class TriggerScriptCompiler {
37 37 Deque<ActionBlock> actionBlocks = new ArrayDeque<>();
38 38 Map<String, String> eventConfiguration = new LinkedHashMap<>();
39 39 int lineNumber = 0;
40 int actionNodes = 0;
41 40 for (String raw : script.lines().toList()) {
42 41 lineNumber++;
43 42 String line = raw.strip();
@@ -58,16 +57,6 @@ public final class TriggerScriptCompiler {
58 57 } else if (line.regionMatches(true, 0, "if ", 0, 3)) {
59 58 Matcher matcher = ACTION_CONDITION.matcher(line);
60 59 if (!matcher.matches()) throw new IllegalArgumentException("invalid if statement");
61 int depth = actionBlocks.size() + 1;
62 if (depth > TriggerActionTree.MAX_CONDITION_DEPTH) {
63 throw new IllegalArgumentException(
64 "trigger action conditions cannot be nested more than 8 levels");
65 }
66 actionNodes++;
67 if (actionNodes > TriggerDefinition.MAX_ACTIONS) {
68 throw new IllegalArgumentException(
69 "a trigger cannot contain more than 32 actions");
70 }
71 60 actionBlocks.push(new ActionBlock(condition(matcher), new ArrayList<>(), lineNumber));
72 61 } else if (line.regionMatches(true, 0, "on ", 0, 3)) {
73 62 requireTopLevel(actionBlocks, "on");
@@ -123,19 +112,11 @@ public final class TriggerScriptCompiler {
123 112 Matcher matcher = CONDITION.matcher(line);
124 113 if (!matcher.matches()) throw new IllegalArgumentException("invalid when statement");
125 114 conditions.add(condition(matcher));
126 if (conditions.size() > TriggerDefinition.MAX_CONDITIONS) {
127 throw new IllegalArgumentException("a trigger cannot contain more than 32 conditions");
128 }
129 115 } else if (line.regionMatches(true, 0, "do ", 0, 3)) {
130 116 String body = line.substring(3).strip();
131 117 int space = body.indexOf(' ');
132 118 String type = space < 0 ? body : body.substring(0, space);
133 119 String rawParameters = space < 0 ? "" : body.substring(space + 1);
134 actionNodes++;
135 if (actionNodes > TriggerDefinition.MAX_ACTIONS) {
136 throw new IllegalArgumentException(
137 "a trigger cannot contain more than 32 actions");
138 }
139 120 addAction(actions, actionBlocks,
140 121 new TriggerDefinition.Action(type, parameters(rawParameters)));
141 122 } else {
Modified common/core/src/main/java/com/xfestudio/xfeservermanager/core/trigger/TriggerValueTypes.java +5 -6
@@ -113,10 +113,10 @@ public final class TriggerValueTypes {
113 113 }
114 114 }
115 115
116 /** Parses a recursively nested array/dictionary type with a bounded generic depth. */
116 /** Parses a recursively nested array/dictionary type. */
117 117 public static VariableType variableType(String type) {
118 118 VariableTypeParser parser = new VariableTypeParser(normalized(type));
119 VariableType result = parser.parse(0);
119 VariableType result = parser.parse();
120 120 parser.whitespace();
121 121 if (!parser.complete()) throw new IllegalArgumentException("unexpected variable type suffix");
122 122 return result;
@@ -250,8 +250,7 @@ public final class TriggerValueTypes {
250 250 this.value = value;
251 251 }
252 252
253 private VariableType parse(int depth) {
254 if (depth > 8) throw new IllegalArgumentException("variable generic nesting is too deep");
253 private VariableType parse() {
255 254 whitespace();
256 255 int start = position;
257 256 while (position < value.length() && (Character.isLetterOrDigit(value.charAt(position))
@@ -267,13 +266,13 @@ public final class TriggerValueTypes {
267 266 throw new IllegalArgumentException(name + " requires generic type arguments");
268 267 }
269 268 List<VariableType> arguments = new ArrayList<>();
270 arguments.add(parse(depth + 1));
269 arguments.add(parse());
271 270 whitespace();
272 271 if (name.equals("dictionary")) {
273 272 if (position >= value.length() || value.charAt(position++) != ',') {
274 273 throw new IllegalArgumentException("dictionary requires key and value generic types");
275 274 }
276 arguments.add(parse(depth + 1));
275 arguments.add(parse());
277 276 if (arguments.get(0).container()) {
278 277 throw new IllegalArgumentException("dictionary key type must be a scalar");
279 278 }
Modified common/infra/src/main/java/com/xfestudio/xfeservermanager/infra/http/HttpApiServer.java +2 -0
@@ -141,6 +141,7 @@ public final class HttpApiServer implements AutoCloseable {
141 141 route(exchange, requestId);
142 142 } catch (AuthenticationException exception) {
143 143 int status = "rate_limited".equals(exception.code()) ? 429 : 401;
144 if (status == 429) exchange.getResponseHeaders().set("Retry-After", "600");
144 145 problem(exchange, status, "Authentication failed", exception.getMessage(), requestId);
145 146 } catch (ReauthenticationRequiredException exception) {
146 147 problem(exchange, 428, "Reauthentication required", exception.getMessage(), requestId);
@@ -203,6 +204,7 @@ public final class HttpApiServer implements AutoCloseable {
203 204 "the operation outcome is not known yet; retry with the same Idempotency-Key", requestId);
204 205 } else if (cause instanceof AuthenticationException authenticationFailure) {
205 206 int status = "rate_limited".equals(authenticationFailure.code()) ? 429 : 401;
207 if (status == 429) exchange.getResponseHeaders().set("Retry-After", "600");
206 208 problem(exchange, status, "Authentication failed", authenticationFailure.getMessage(), requestId);
207 209 } else if (cause instanceof ReauthenticationRequiredException) {
208 210 problem(exchange, 428, "Reauthentication required", safeMessage(cause), requestId);
Modified common/infra/src/main/java/com/xfestudio/xfeservermanager/infra/persistence/SqliteTriggerRepository.java +1 -6
@@ -742,19 +742,14 @@ public final class SqliteTriggerRepository {
742 742 static List<TriggerDefinition.Action> messages(List<String> values) {
743 743 List<TriggerDefinition.Action> actions = new ArrayList<>();
744 744 for (String value : values) {
745 if (actions.size() >= TriggerDefinition.MAX_ACTIONS) break;
746 745 if (value == null || value.isBlank()) continue;
747 746 if (value.startsWith(RICH_TEXT_PREFIX)) {
748 747 actions.add(new TriggerDefinition.Action("send_player", Map.of("message", value)));
749 748 continue;
750 749 }
751 750 // Legacy delivery rendered, trimmed and sent each non-empty line independently.
752 // It then applied one global limit(32) to the prepared join messages. Therefore a
753 // category's 33rd line could never be delivered, even when every earlier category was
754 // disabled. Keeping only this reachable prefix both preserves that behavior and keeps
755 // every migrated TriggerDefinition within its durable action bound.
751 // Preserve every reachable line now that trigger action trees have no authoring cap.
756 752 for (String line : value.lines().map(String::strip).filter(entry -> !entry.isEmpty()).toList()) {
757 if (actions.size() >= TriggerDefinition.MAX_ACTIONS) break;
758 753 actions.add(new TriggerDefinition.Action("send_player", Map.of("message", line)));
759 754 }
760 755 }
Modified common/infra/src/main/java/com/xfestudio/xfeservermanager/infra/security/AuthenticationService.java +32 -2
@@ -23,6 +23,8 @@ public final class AuthenticationService {
23 23 private static final int RECOVERY_CODE_COUNT = 10;
24 24 private static final int MAXIMUM_ACTIVE_SESSIONS_PER_ACCOUNT = 20;
25 25 private static final int MAXIMUM_MANAGEMENT_ACCOUNTS = 100;
26 private static final int MAXIMUM_SECOND_FACTOR_ATTEMPTS = 3;
27 private static final Duration SECOND_FACTOR_ATTEMPT_WINDOW = Duration.ofMinutes(10);
26 28
27 29 private final SqliteAuthRepository repository;
28 30 private final PasswordHasher passwordHasher;
@@ -30,6 +32,7 @@ public final class AuthenticationService {
30 32 private final AesGcmSecretBox secretBox;
31 33 private final BootstrapTokenService bootstrapTokens;
32 34 private final SlidingWindowRateLimiter rateLimiter;
35 private final SlidingWindowRateLimiter secondFactorRateLimiter;
33 36 private final ServerConfiguration configuration;
34 37 private final Clock clock;
35 38 private final String dummyPasswordHash;
@@ -54,6 +57,8 @@ public final class AuthenticationService {
54 57 this.rateLimiter = rateLimiter;
55 58 this.configuration = configuration;
56 59 this.clock = clock;
60 this.secondFactorRateLimiter = new SlidingWindowRateLimiter(
61 MAXIMUM_SECOND_FACTOR_ATTEMPTS, SECOND_FACTOR_ATTEMPT_WINDOW, clock);
57 62 char[] dummy = DUMMY_PASSWORD.toCharArray();
58 63 try {
59 64 this.dummyPasswordHash = passwordHasher.hash(dummy);
@@ -256,6 +261,8 @@ public final class AuthenticationService {
256 261 public List<String> confirmTotpEnrollment(
257 262 AuthenticatedPrincipal principal, String suppliedCode) {
258 263 AccountRecord account = requireActiveAccount(principal);
264 String limiterKey = secondFactorLimiterKey(account.id());
265 requireSecondFactorAttempt(limiterKey);
259 266 PendingTotpEnrollment enrollment = pendingTotp.get(account.id());
260 267 if (enrollment == null || !clock.instant().isBefore(enrollment.expiresAt())) {
261 268 PendingTotpEnrollment removed = pendingTotp.remove(account.id());
@@ -290,6 +297,7 @@ public final class AuthenticationService {
290 297 recoveryHashes,
291 298 principal.sessionId(),
292 299 now);
300 secondFactorRateLimiter.reset(limiterKey);
293 301 return List.copyOf(recoveryCodes);
294 302 } finally {
295 303 enrollment.destroy();
@@ -579,11 +587,25 @@ public final class AuthenticationService {
579 587 }
580 588 }
581 589
590 private void requireSecondFactorAttempt(String limiterKey) {
591 if (!secondFactorRateLimiter.tryAcquire(limiterKey)) {
592 throw new AuthenticationException(
593 "rate_limited",
594 "too many two-factor verification attempts; wait 10 minutes before trying again");
595 }
596 }
597
598 private static String secondFactorLimiterKey(UUID accountId) {
599 return "second-factor|" + accountId;
600 }
601
582 602 private SecondFactorResult verifyAccountSecondFactor(
583 603 AccountRecord account, String suppliedCode, boolean allowRecoveryCode) {
584 604 if (account.encryptedTotpSecret() == null) {
585 605 return SecondFactorResult.NONE;
586 606 }
607 String limiterKey = secondFactorLimiterKey(account.id());
608 requireSecondFactorAttempt(limiterKey);
587 609 byte[] secret = secretBox.decrypt(
588 610 account.encryptedTotpSecret(),
589 611 account.id().toString().getBytes(StandardCharsets.UTF_8));
@@ -592,6 +614,7 @@ public final class AuthenticationService {
592 614 if (matchingCounter.isPresent()
593 615 && repository.consumeTotpCounter(
594 616 account.id(), matchingCounter.getAsLong(), clock.instant())) {
617 secondFactorRateLimiter.reset(limiterKey);
595 618 return SecondFactorResult.TOTP;
596 619 }
597 620 } finally {
@@ -603,6 +626,7 @@ public final class AuthenticationService {
603 626 account.id(),
604 627 SecureTokens.sha256(normalizeRecoveryCode(suppliedCode)),
605 628 clock.instant());
629 if (recovered) secondFactorRateLimiter.reset(limiterKey);
606 630 return recovered ? SecondFactorResult.RECOVERY : SecondFactorResult.NONE;
607 631 }
608 632
@@ -611,6 +635,8 @@ public final class AuthenticationService {
611 635 if (account.encryptedTotpSecret() == null) {
612 636 return false;
613 637 }
638 String limiterKey = secondFactorLimiterKey(account.id());
639 requireSecondFactorAttempt(limiterKey);
614 640 Instant now = clock.instant();
615 641 byte[] secret = secretBox.decrypt(
616 642 account.encryptedTotpSecret(),
@@ -618,17 +644,21 @@ public final class AuthenticationService {
618 644 try {
619 645 var counter = totpService.matchingCounter(secret, suppliedCode);
620 646 if (counter.isPresent()) {
621 return repository.consumeTotpCounterAndMarkSession(
647 boolean accepted = repository.consumeTotpCounterAndMarkSession(
622 648 account.id(), sessionId, counter.getAsLong(), now);
649 if (accepted) secondFactorRateLimiter.reset(limiterKey);
650 return accepted;
623 651 }
624 652 } finally {
625 653 Arrays.fill(secret, (byte) 0);
626 654 }
627 return suppliedCode != null && repository.consumeRecoveryCodeAndMarkSession(
655 boolean recovered = suppliedCode != null && repository.consumeRecoveryCodeAndMarkSession(
628 656 account.id(),
629 657 sessionId,
630 658 SecureTokens.sha256(normalizeRecoveryCode(suppliedCode)),
631 659 now);
660 if (recovered) secondFactorRateLimiter.reset(limiterKey);
661 return recovered;
632 662 }
633 663
634 664 private static String normalizeRecoveryCode(String code) {
Modified common/infra/src/test/java/com/xfestudio/xfeservermanager/infra/persistence/SqliteTriggerRepositoryTest.java +5 -5
@@ -63,7 +63,7 @@ class SqliteTriggerRepositoryTest {
63 63 }
64 64
65 65 @Test
66 void migratesOnlyTheReachablePrefixOfLongWelcomeAndMultilineDailyMessages() {
66 void migratesEveryLongWelcomeAndMultilineDailyMessage() {
67 67 var database = database();
68 68 var repository = new SqliteTriggerRepository(database, Clock.systemUTC());
69 69 String welcome = IntStream.rangeClosed(1, 33)
@@ -79,13 +79,13 @@ class SqliteTriggerRepositoryTest {
79 79 .filter(trigger -> trigger.name().startsWith("Every-join")).findFirst().orElseThrow();
80 80 TriggerDefinition migratedDaily = firstMigration.stream()
81 81 .filter(trigger -> trigger.name().startsWith("Daily announcements")).findFirst().orElseThrow();
82 assertThat(migratedWelcome.actions()).hasSize(TriggerDefinition.MAX_ACTIONS);
82 assertThat(migratedWelcome.actions()).hasSize(33);
83 83 assertThat(migratedWelcome.actions()).extracting(action -> action.parameters().get("message"))
84 .containsExactlyElementsOf(IntStream.rangeClosed(1, 32)
84 .containsExactlyElementsOf(IntStream.rangeClosed(1, 33)
85 85 .mapToObj(index -> "welcome-" + index).toList());
86 assertThat(migratedDaily.actions()).hasSize(TriggerDefinition.MAX_ACTIONS);
86 assertThat(migratedDaily.actions()).hasSize(40);
87 87 assertThat(migratedDaily.actions().get(0).parameters()).containsEntry("message", "daily-1-a");
88 assertThat(migratedDaily.actions().get(31).parameters()).containsEntry("message", "daily-16-b");
88 assertThat(migratedDaily.actions().get(39).parameters()).containsEntry("message", "daily-20-b");
89 89 assertThat(repository.triggers()).extracting(TriggerDefinition::id)
90 90 .containsExactlyElementsOf(firstMigration.stream().map(TriggerDefinition::id).toList());
91 91 }
Modified common/infra/src/test/java/com/xfestudio/xfeservermanager/infra/security/AuthenticationServiceTest.java +29 -0
@@ -334,6 +334,35 @@ class AuthenticationServiceTest {
334 334 Arrays.fill(secret, (byte) 0);
335 335 }
336 336
337 @Test
338 void rateLimitsSecondFactorAttemptsPerAccount() {
339 IssuedSession owner = createOwner();
340 var enrollment = authentication.beginTotpEnrollment(
341 owner.principal(), "correct horse battery staple".toCharArray());
342 byte[] secret = decodeBase32(enrollment.secret());
343 String enrollmentCode = new TotpService(clock).formatCode(new TotpService(clock).currentCode(secret));
344 authentication.confirmTotpEnrollment(owner.principal(), enrollmentCode);
345 clock.advance(Duration.ofSeconds(30));
346
347 AuthenticatedPrincipal principal = authentication.authenticate(owner.sessionToken()).orElseThrow();
348 for (int attempt = 0; attempt < 3; attempt++) {
349 assertThatThrownBy(() -> authentication.reauthenticateWithTotp(principal, enrollmentCode))
350 .isInstanceOf(AuthenticationService.AuthenticationException.class)
351 .hasMessageContaining("invalid TOTP");
352 }
353 String validButLocked = new TotpService(clock).formatCode(new TotpService(clock).currentCode(secret));
354 assertThatThrownBy(() -> authentication.reauthenticateWithTotp(principal, validButLocked))
355 .isInstanceOf(AuthenticationService.AuthenticationException.class)
356 .hasMessageContaining("wait 10 minutes");
357
358 clock.advance(Duration.ofMinutes(10));
359 String validAfterWindow = new TotpService(clock).formatCode(new TotpService(clock).currentCode(secret));
360 authentication.reauthenticateWithTotp(principal, validAfterWindow);
361 assertThat(authentication.recentlyAuthenticated(
362 authentication.authenticate(owner.sessionToken()).orElseThrow())).isTrue();
363 Arrays.fill(secret, (byte) 0);
364 }
365
337 366 @Test
338 367 void restartingEnrollmentCannotRefreshAnExistingTotpSession() {
339 368 IssuedSession owner = createOwner();
Modified common/testkit/src/test/java/com/xfestudio/xfeservermanager/core/trigger/TriggerCommandConfigurationTest.java +4 -4
Modified common/testkit/src/test/java/com/xfestudio/xfeservermanager/core/trigger/TriggerEngineTest.java +43 -32
Modified common/testkit/src/test/java/com/xfestudio/xfeservermanager/core/trigger/TriggerValueTypesTest.java +13 -0
Modified docs/implementation-status.md +3 -3
Modified docs/openapi.yaml +26 -17
Modified docs/security.md +1 -1
Modified platform/shared/src/forge-common/java/com/xfestudio/xfeservermanager/platform/forge/ForgeManagementGateway.java +58 -17
Modified platform/shared/src/forge-common/java/com/xfestudio/xfeservermanager/platform/forge/ForgeTriggerService.java +24 -19
Modified platform/shared/src/test/java/com/xfestudio/xfeservermanager/platform/forge/ForgeTriggerServiceTest.java +16 -4
Modified scripts/build-all.ps1 +3 -0
Modified web-ui/src/pages/triggers.test.tsx +59 -9
Modified web-ui/src/pages/triggers.tsx +83 -52