feat(auth): migrate console login to passwordless
Replace console password auth with a passwordless surface — the pre-session
login doors plus an identifier-first discovery endpoint — and remove the
password paths.
- Login doors (Public, pre-session): email-OTP, passkey assertion, op.console
login with in-game approval, and setup-token redeem.
- /api/v1/auth/options: identifier-first discovery reporting which console
methods an email can use. The single sanctioned existence oracle; methods
are computed with no role branch, so staff and player accounts in the same
credential state return byte-identical bodies (staffness invisible by
construction).
- Remove password auth: drop StaffUser.PasswordHash and the /auth/login,
/auth/change-password and /users/{id}/reset-password endpoints (and test).
- Data layer: UserByEmail, verified-email uniqueness, setup-token store
(migration 0012).
- Reconcile docs/openapi.yaml with the served surface; the method/path/face/
tier parity gate (TestOpenAPIMatchesServedRoutes) passes.
- felis TUI: in-game MC bind, owner/break-glass OP provisioning, version.
- Velocity /felis command suite.
Consolidates the accumulated backend migration work; the frontend (panel/)
is left untouched. Full Go tree green on WSL (go build ./... && go test ./...).
This commit is contained in:
46 files changed
+5554
-1651
No files matched your search
+306
-5
@@ -8,6 +8,7 @@ import best.lolicon.felis.link.ServerView;
|
||||
|
||||
import com.google.inject.Inject;
|
||||
import com.mojang.brigadier.Command;
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.mojang.brigadier.tree.LiteralCommandNode;
|
||||
import com.velocitypowered.api.command.BrigadierCommand;
|
||||
import com.velocitypowered.api.command.CommandManager;
|
||||
@@ -19,6 +20,7 @@ import com.velocitypowered.api.plugin.Plugin;
|
||||
import com.velocitypowered.api.plugin.annotation.DataDirectory;
|
||||
import com.velocitypowered.api.proxy.Player;
|
||||
import com.velocitypowered.api.proxy.ProxyServer;
|
||||
import com.velocitypowered.api.proxy.ServerConnection;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.slf4j.Logger;
|
||||
@@ -27,6 +29,9 @@ import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* FelisVelocityPlugin is the proxy-side of Felis (spec §10 account-link + §11
|
||||
@@ -72,6 +77,7 @@ public final class FelisVelocityPlugin {
|
||||
private LinkClient linkClient;
|
||||
private FelisApiClient apiClient;
|
||||
private ServerRegistry registry;
|
||||
private WaitingRouter router;
|
||||
private boolean onlineMode;
|
||||
private boolean routingActive;
|
||||
|
||||
@@ -110,7 +116,7 @@ public final class FelisVelocityPlugin {
|
||||
|
||||
this.apiClient = new FelisApiClient(config.linkConfig());
|
||||
this.registry = new ServerRegistry(proxy, logger, config.rootDomain());
|
||||
WaitingRouter router = new WaitingRouter(proxy, logger, apiClient, registry, this, config.lobbyServer());
|
||||
this.router = new WaitingRouter(proxy, logger, apiClient, registry, this, config.lobbyServer());
|
||||
MotdResponder motd = new MotdResponder(registry);
|
||||
proxy.getEventManager().register(this, router);
|
||||
proxy.getEventManager().register(this, motd);
|
||||
@@ -197,7 +203,42 @@ public final class FelisVelocityPlugin {
|
||||
});
|
||||
}
|
||||
|
||||
// ---- /felis (operator status) ----
|
||||
// ---- /felis command suite (spec §B in-game authz; P4) ----
|
||||
//
|
||||
// /felis is the proxy-wide operator surface: the ONE place a command runs with a
|
||||
// service token behind it AND a Mojang-verified UUID in front of it, so it is where
|
||||
// the in-game half of the passwordless-auth flows lives. The tree:
|
||||
//
|
||||
// /felis proxy + routing status
|
||||
// /felis help this list
|
||||
// /felis server the felis servers this proxy knows
|
||||
// /felis go <server> wake a server and move me in when it's ready
|
||||
// /felis claim take ownership of the server I'm on
|
||||
// /felis web where the web consoles live
|
||||
// /felis web op approve <code> vouch for a pending op.console staff login (§B)
|
||||
//
|
||||
// Two guards run before any subcommand that acts or reveals operational state:
|
||||
//
|
||||
// - the login-limbo gate — a player still sitting in the "login" system server
|
||||
// (naming.SystemLoginServer) has not passed the front door, so every acting
|
||||
// subcommand is refused there; only `help` is always available. The console
|
||||
// source is the trusted operator terminal and is never "in limbo".
|
||||
// - player-only actions (go / claim / web op approve) reject the console: they
|
||||
// derive identity from the caller's verified UUID, which only a Player carries,
|
||||
// so an op-login approver is provably online as themselves (spec §14). The
|
||||
// API independently re-checks that the UUID is a linked admin — this gate is
|
||||
// defence in depth over that, not a substitute for it.
|
||||
|
||||
/** Opaque-handle charset an op-login code must match before it enters a request
|
||||
* path: the id minted by op-login start is 32 hex, but a conservative url-safe set
|
||||
* is accepted so a future id format still passes while path-dangerous input
|
||||
* (slash, dot, whitespace) is refused client-side rather than sent. */
|
||||
private static final Pattern OP_LOGIN_CODE = Pattern.compile("^[A-Za-z0-9_-]{1,128}$");
|
||||
|
||||
/** The always-on login limbo (LOOHP/Limbo) — the reserved system name
|
||||
* {@code naming.SystemLoginServer}, which users can never claim, so gating on the
|
||||
* server name is stable. */
|
||||
private static final String LOGIN_LIMBO = "login";
|
||||
|
||||
private void registerFelisCommand() {
|
||||
CommandManager commands = proxy.getCommandManager();
|
||||
@@ -206,31 +247,120 @@ public final class FelisVelocityPlugin {
|
||||
sendSummary(ctx.getSource());
|
||||
return Command.SINGLE_SUCCESS;
|
||||
})
|
||||
.then(BrigadierCommand.literalArgumentBuilder("list")
|
||||
.then(BrigadierCommand.literalArgumentBuilder("help")
|
||||
.executes(ctx -> {
|
||||
sendList(ctx.getSource());
|
||||
sendHelp(ctx.getSource());
|
||||
return Command.SINGLE_SUCCESS;
|
||||
}))
|
||||
.then(BrigadierCommand.literalArgumentBuilder("server")
|
||||
.executes(ctx -> {
|
||||
sendServerList(ctx.getSource());
|
||||
return Command.SINGLE_SUCCESS;
|
||||
}))
|
||||
.then(BrigadierCommand.literalArgumentBuilder("go")
|
||||
.then(BrigadierCommand.requiredArgumentBuilder("server", StringArgumentType.word())
|
||||
.executes(ctx -> {
|
||||
doGo(ctx.getSource(), StringArgumentType.getString(ctx, "server"));
|
||||
return Command.SINGLE_SUCCESS;
|
||||
})))
|
||||
.then(BrigadierCommand.literalArgumentBuilder("claim")
|
||||
.executes(ctx -> {
|
||||
doClaim(ctx.getSource());
|
||||
return Command.SINGLE_SUCCESS;
|
||||
}))
|
||||
.then(BrigadierCommand.literalArgumentBuilder("web")
|
||||
.executes(ctx -> {
|
||||
sendWebInfo(ctx.getSource());
|
||||
return Command.SINGLE_SUCCESS;
|
||||
})
|
||||
.then(BrigadierCommand.literalArgumentBuilder("op")
|
||||
.executes(ctx -> {
|
||||
sendWebOpInfo(ctx.getSource());
|
||||
return Command.SINGLE_SUCCESS;
|
||||
})
|
||||
.then(BrigadierCommand.literalArgumentBuilder("approve")
|
||||
.then(BrigadierCommand.requiredArgumentBuilder("code", StringArgumentType.word())
|
||||
.executes(ctx -> {
|
||||
doOpApprove(ctx.getSource(), StringArgumentType.getString(ctx, "code"));
|
||||
return Command.SINGLE_SUCCESS;
|
||||
})))))
|
||||
.build();
|
||||
CommandMeta meta = commands.metaBuilder("felis").plugin(this).build();
|
||||
commands.register(meta, new BrigadierCommand(node));
|
||||
}
|
||||
|
||||
// ---- guards ----
|
||||
|
||||
// requirePlayer refuses the console for identity-bound actions (go / claim /
|
||||
// approve): they act on the caller's Mojang-verified UUID, which only a Player has.
|
||||
private Player requirePlayer(CommandSource source) {
|
||||
if (source instanceof Player) {
|
||||
return (Player) source;
|
||||
}
|
||||
source.sendMessage(Component.text("That command can only be run in-game by a player.", NamedTextColor.RED));
|
||||
return null;
|
||||
}
|
||||
|
||||
// ensureOutOfLimbo refuses an acting subcommand while the player is still in the
|
||||
// login limbo (or not yet on any backend): they have not passed the front door.
|
||||
// Fails closed on an unknown position.
|
||||
private boolean ensureOutOfLimbo(Player player) {
|
||||
Optional<ServerConnection> current = player.getCurrentServer();
|
||||
if (current.isEmpty()) {
|
||||
player.sendMessage(Component.text(
|
||||
"Hold on — finish connecting before using /felis.", NamedTextColor.YELLOW));
|
||||
return false;
|
||||
}
|
||||
if (LOGIN_LIMBO.equalsIgnoreCase(current.get().getServerInfo().getName())) {
|
||||
player.sendMessage(Component.text(
|
||||
"Finish signing in first — /felis isn't available from the login area.",
|
||||
NamedTextColor.YELLOW));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// gateInfo applies only the limbo gate (the console is always allowed) for the
|
||||
// read-only, operational-info subcommands. Returns true when the caller may proceed.
|
||||
private boolean gateInfo(CommandSource source) {
|
||||
return !(source instanceof Player) || ensureOutOfLimbo((Player) source);
|
||||
}
|
||||
|
||||
// ---- handlers ----
|
||||
|
||||
private void sendSummary(CommandSource source) {
|
||||
if (!gateInfo(source)) {
|
||||
return;
|
||||
}
|
||||
source.sendMessage(Component.text("Felis proxy", NamedTextColor.AQUA));
|
||||
source.sendMessage(field("online-mode", String.valueOf(onlineMode)));
|
||||
if (!routingActive) {
|
||||
source.sendMessage(Component.text(
|
||||
" routing: disabled" + (onlineMode ? " (no root-domain set)" : " (offline mode)"),
|
||||
NamedTextColor.YELLOW));
|
||||
source.sendMessage(Component.text(" /felis help for commands", NamedTextColor.GRAY));
|
||||
return;
|
||||
}
|
||||
source.sendMessage(field("root-domain", config.rootDomain()));
|
||||
source.sendMessage(field("lobby", config.lobbyServer() == null ? "<none>" : config.lobbyServer()));
|
||||
source.sendMessage(field("servers", String.valueOf(registry.all().size())));
|
||||
source.sendMessage(Component.text(" /felis help for commands", NamedTextColor.GRAY));
|
||||
}
|
||||
|
||||
private void sendList(CommandSource source) {
|
||||
private void sendHelp(CommandSource source) {
|
||||
source.sendMessage(Component.text("Felis commands", NamedTextColor.AQUA));
|
||||
helpLine(source, "/felis", "proxy and routing status");
|
||||
helpLine(source, "/felis server", "the felis servers this proxy knows");
|
||||
helpLine(source, "/felis go <server>", "start a server and move you in when it's ready");
|
||||
helpLine(source, "/felis claim", "take ownership of the server you're on");
|
||||
helpLine(source, "/felis web", "where the web consoles live");
|
||||
helpLine(source, "/felis web op approve <code>", "approve a pending operator sign-in");
|
||||
}
|
||||
|
||||
private void sendServerList(CommandSource source) {
|
||||
if (!gateInfo(source)) {
|
||||
return;
|
||||
}
|
||||
if (!routingActive) {
|
||||
source.sendMessage(Component.text("Felis routing is disabled.", NamedTextColor.YELLOW));
|
||||
return;
|
||||
@@ -249,6 +379,177 @@ public final class FelisVelocityPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
private void doGo(CommandSource source, String serverArg) {
|
||||
Player player = requirePlayer(source);
|
||||
if (player == null || !ensureOutOfLimbo(player)) {
|
||||
return;
|
||||
}
|
||||
if (!routingActive) {
|
||||
player.sendMessage(routingDisabled());
|
||||
return;
|
||||
}
|
||||
String target = serverArg.trim();
|
||||
ServerView match = null;
|
||||
for (ServerView v : registry.all()) {
|
||||
if (v.name().equalsIgnoreCase(target)) {
|
||||
match = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match == null) {
|
||||
player.sendMessage(Component.text(
|
||||
"No felis server named « " + target + " ». Try /felis server.", NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
Optional<ServerConnection> current = player.getCurrentServer();
|
||||
if (current.isPresent() && current.get().getServerInfo().getName().equalsIgnoreCase(match.name())) {
|
||||
player.sendMessage(Component.text("You're already on « " + match.name() + " ».", NamedTextColor.GRAY));
|
||||
return;
|
||||
}
|
||||
// Wake + park + transfer through the shared waiting queue; it reports its own
|
||||
// policy-gate (403) and transient refusals to the player.
|
||||
router.enqueueFromCommand(player, match.name());
|
||||
}
|
||||
|
||||
private void doClaim(CommandSource source) {
|
||||
Player player = requirePlayer(source);
|
||||
if (player == null || !ensureOutOfLimbo(player)) {
|
||||
return;
|
||||
}
|
||||
if (!routingActive) {
|
||||
player.sendMessage(routingDisabled());
|
||||
return;
|
||||
}
|
||||
Optional<ServerConnection> current = player.getCurrentServer();
|
||||
if (current.isEmpty()) {
|
||||
player.sendMessage(Component.text("Join a server before claiming it.", NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
String name = current.get().getServerInfo().getName();
|
||||
if (!registry.isManaged(name)) {
|
||||
player.sendMessage(Component.text(
|
||||
"« " + name + " » isn't a claimable felis server.", NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
UUID uuid = player.getUniqueId();
|
||||
player.sendMessage(Component.text("Claiming « " + name + " »…", NamedTextColor.GRAY));
|
||||
async(() -> {
|
||||
try {
|
||||
apiClient.claim(name, uuid);
|
||||
player.sendMessage(Component.text("You now own « " + name + " ».", NamedTextColor.GREEN));
|
||||
} catch (LinkException e) {
|
||||
player.sendMessage(Component.text(claimError(e, name), NamedTextColor.RED));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void sendWebInfo(CommandSource source) {
|
||||
if (!gateInfo(source)) {
|
||||
return;
|
||||
}
|
||||
String root = config.rootDomain();
|
||||
if (root == null) {
|
||||
source.sendMessage(Component.text(
|
||||
"The web console isn't configured on this proxy.", NamedTextColor.YELLOW));
|
||||
return;
|
||||
}
|
||||
source.sendMessage(Component.text("Felis web consoles", NamedTextColor.AQUA));
|
||||
source.sendMessage(field("players", "https://console." + root));
|
||||
source.sendMessage(field("operators", "https://op.console." + root));
|
||||
source.sendMessage(Component.text(
|
||||
" operators: /felis web op approve <code> vouches for a pending sign-in",
|
||||
NamedTextColor.GRAY));
|
||||
}
|
||||
|
||||
private void sendWebOpInfo(CommandSource source) {
|
||||
if (!gateInfo(source)) {
|
||||
return;
|
||||
}
|
||||
source.sendMessage(Component.text("Operator sign-in", NamedTextColor.AQUA));
|
||||
source.sendMessage(Component.text(
|
||||
"An operator signing in at op.console shows an approval code. Run", NamedTextColor.GRAY));
|
||||
source.sendMessage(Component.text(" /felis web op approve <code>", NamedTextColor.WHITE));
|
||||
source.sendMessage(Component.text(
|
||||
"to vouch for it — you must be an online, linked administrator.", NamedTextColor.GRAY));
|
||||
}
|
||||
|
||||
private void doOpApprove(CommandSource source, String codeArg) {
|
||||
Player player = requirePlayer(source);
|
||||
if (player == null || !ensureOutOfLimbo(player)) {
|
||||
return;
|
||||
}
|
||||
if (!routingActive) {
|
||||
player.sendMessage(routingDisabled());
|
||||
return;
|
||||
}
|
||||
String code = codeArg.trim();
|
||||
if (!OP_LOGIN_CODE.matcher(code).matches()) {
|
||||
player.sendMessage(Component.text(
|
||||
"That doesn't look like a valid approval code.", NamedTextColor.RED));
|
||||
return;
|
||||
}
|
||||
UUID approver = player.getUniqueId();
|
||||
String who = player.getUsername();
|
||||
player.sendMessage(Component.text("Approving operator sign-in…", NamedTextColor.GRAY));
|
||||
async(() -> {
|
||||
try {
|
||||
apiClient.opLoginApprove(code, approver);
|
||||
player.sendMessage(Component.text(
|
||||
"Approved — the operator can finish signing in now.", NamedTextColor.GREEN));
|
||||
logger.info("Felis: op-login {} approved in-game by {} ({})", code, who, approver);
|
||||
} catch (LinkException e) {
|
||||
player.sendMessage(Component.text(opApproveError(e), NamedTextColor.RED));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private Component routingDisabled() {
|
||||
return Component.text(
|
||||
"Felis routing is disabled on this proxy" + (onlineMode ? " (no root-domain set)." : " (offline mode)."),
|
||||
NamedTextColor.YELLOW);
|
||||
}
|
||||
|
||||
// claimError maps the felis-api claim refusals (spec §9.3) to player-safe text.
|
||||
private static String claimError(LinkException e, String server) {
|
||||
switch (e.statusCode()) {
|
||||
case 412:
|
||||
return "Link your account on the web console before claiming a server.";
|
||||
case 403:
|
||||
return "You've reached your server limit — you can't claim another.";
|
||||
case 409:
|
||||
return "« " + server + " » is already owned.";
|
||||
case 404:
|
||||
return "« " + server + " » is no longer available.";
|
||||
case 0:
|
||||
return "Felis is temporarily unavailable — please try again.";
|
||||
default:
|
||||
return "Couldn't claim « " + server + " » right now. Please try again.";
|
||||
}
|
||||
}
|
||||
|
||||
// opApproveError maps the internal approve refusals to player-safe text. A 403 is
|
||||
// the API's own admin re-check (defence in depth over the in-game gate); a 404
|
||||
// means no live pending request carries that code.
|
||||
private static String opApproveError(LinkException e) {
|
||||
switch (e.statusCode()) {
|
||||
case 403:
|
||||
return "Only a linked administrator may approve an operator sign-in.";
|
||||
case 404:
|
||||
return "No pending operator sign-in with that code (it may have expired).";
|
||||
case 0:
|
||||
return "Felis is temporarily unavailable — please try again.";
|
||||
default:
|
||||
return "Couldn't approve that sign-in right now. Please try again.";
|
||||
}
|
||||
}
|
||||
|
||||
private static void helpLine(CommandSource source, String cmd, String desc) {
|
||||
source.sendMessage(Component.text(" " + cmd + " ", NamedTextColor.WHITE)
|
||||
.append(Component.text("— " + desc, NamedTextColor.GRAY)));
|
||||
}
|
||||
|
||||
private static Component field(String key, String value) {
|
||||
return Component.text(" " + key + ": ", NamedTextColor.GRAY)
|
||||
.append(Component.text(value == null ? "<unset>" : value, NamedTextColor.WHITE));
|
||||
|
||||
@@ -94,6 +94,19 @@ public final class WaitingRouter {
|
||||
wakeAndWait(player, serverName, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* enqueueFromCommand parks a player who drove {@code /felis go <server>} from chat
|
||||
* onto the server they named, then wakes it and lets {@link #tick()} transfer them
|
||||
* when ready — the same shared waiting queue as host-based routing and the menu
|
||||
* path, differing only in that it is NOT flagged {@code fromMenu}: a command-driven
|
||||
* go has no felis-paper GUI tile to notify, so no {@code TransferReady} frame is
|
||||
* emitted on readiness. The wake stays autostartPolicy-gated on the verified UUID
|
||||
* exactly as the other origins, so this adds a new entry point, not a new authority.
|
||||
*/
|
||||
void enqueueFromCommand(Player player, String serverName) {
|
||||
wakeAndWait(player, serverName, false);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onChooseInitialServer(PlayerChooseInitialServerEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
|
||||
Reference in new issue
Block a user