返回提交历史
Modified
common/core/src/main/java/com/xfestudio/xfeservermanager/core/trigger/TriggerActionTree.java
+4
-21
Modified
common/core/src/main/java/com/xfestudio/xfeservermanager/core/trigger/TriggerCatalog.java
+32
-5
Modified
common/core/src/main/java/com/xfestudio/xfeservermanager/core/trigger/TriggerCommandConfiguration.java
+4
-24
Modified
common/core/src/main/java/com/xfestudio/xfeservermanager/core/trigger/TriggerDefinition.java
+0
-11
Modified
common/core/src/main/java/com/xfestudio/xfeservermanager/core/trigger/TriggerJsonValueParser.java
+9
-20
Modified
common/core/src/main/java/com/xfestudio/xfeservermanager/core/trigger/TriggerScriptCompiler.java
+0
-19
Modified
common/core/src/main/java/com/xfestudio/xfeservermanager/core/trigger/TriggerValueTypes.java
+5
-6
Modified
common/infra/src/main/java/com/xfestudio/xfeservermanager/infra/http/HttpApiServer.java
+2
-0
Modified
common/infra/src/main/java/com/xfestudio/xfeservermanager/infra/persistence/SqliteTriggerRepository.java
+1
-6
Modified
common/infra/src/main/java/com/xfestudio/xfeservermanager/infra/security/AuthenticationService.java
+32
-2
Modified
common/infra/src/test/java/com/xfestudio/xfeservermanager/infra/persistence/SqliteTriggerRepositoryTest.java
+5
-5
Modified
common/infra/src/test/java/com/xfestudio/xfeservermanager/infra/security/AuthenticationServiceTest.java
+29
-0
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
XFEstudio/XFEServerManager
feat: expand trigger economy and harden authentication
6fa3c00
代码差异
24 个文件
+456
-277
@@ -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
}
@@ -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))) {
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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 {
@@ -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
}
@@ -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);
@@ -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
}
@@ -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) {
@@ -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
}
@@ -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();