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:
flyemoji committed 2026-07-04 21:47:12 +09:00
1 parent 627883e89a
commit 0c1cc598c1
46 files changed
+5554 -1651

No files matched your search

@@ -173,6 +173,33 @@ public final class FelisApiClient {
return barred instanceof Boolean && (Boolean) barred;
}
/**
* opLoginApprove records an in-game administrator's vouch for a pending op.console
* staff login — the second factor of the spec §B op-login door, supplied from
* Velocity's {@code /felis web op approve <code>}. It POSTs the approver's verified
* online-mode UUID to {@code POST /api/v1/internal/op-login/{id}/approve}; felis-api
* resolves that UUID to a linked account and refuses unless it is {@code role=admin}
* (403 {@code not_admin}), so this is defence in depth over Velocity's own in-game
* guard rather than the sole check. A {@code requestId} naming no live pending
* request is 404 {@code op_login_not_found}. Both arrive as branchable
* {@link LinkException}s; a 200 that does not affirm {@code approved:true} is a
* contract breach, not a refusal.
*
* <p>{@code requestId} is interpolated into the request path, so the caller must
* pass a validated opaque handle (the 32-hex id minted by op-login start) — never
* unsanitised chat input. The Velocity command validates the charset first.
*/
public void opLoginApprove(String requestId, UUID approverUuid) throws LinkException {
Objects.requireNonNull(requestId, "requestId");
Objects.requireNonNull(approverUuid, "approverUuid");
String body = "{\"approver_uuid\":\"" + approverUuid + "\"}";
Map<?, ?> res = postObject("/api/v1/internal/op-login/" + requestId + "/approve", body, 200);
Object approved = res.get("approved");
if (!(approved instanceof Boolean) || !((Boolean) approved)) {
throw new LinkException(200, "bad_response", "approve returned 200 without approved=true");
}
}
// ---- transport ----
private Map<?, ?> getObject(String path, int expect) throws LinkException {
@@ -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();