Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add web player authentication. Closes GH-48 #76

Merged
merged 12 commits into from
May 27, 2017
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ The settings are as follows:
Configures the HTTP and WebSocket server:

* `port` (4081): TCP port for the HTTP server to listen on
* `publicURL` (http://localhost:4081/) - URL for publicly accessing this server, sent to clients when running the `/websandbox auth` command
* `takeover` (false): advanced experimental option to reuse the server port from Bukkit (ignoring `port`) before startup, allowing this plugin to be used on hosts where only one port is allowed
* `unbind_method` ('console.getServerConnection.b'): if `takeover` enabled, this method is called on `Bukkit.getServer()`, may need to change depending on your Bukkit server implementation

Expand All @@ -54,6 +55,8 @@ Configures what part of your world to expose:
* `z_center` (0): " ", Z coordinate
* If x/y/z center are all 0, then the world's spawn location is used instead
* `radius` (16): range out of the center to expose in each direction (cube), setting too high will slow down web client loading
* `clickable_links` (true): send clickable links in chat commands from `/websandbox auth` if true, or as plain text if false
* `clickable_links_tellraw` (false): use the `/tellraw` command to send richly formatted messages if true, or use the TextComponents API if false, change this if you get a formatting error with `/websandbox auth`
* `entity` ("Sheep"): name of entity class to spawn on server for web users, set to "" to disable
* `entity_custom_names` (true): add web player names to the spawned entity's nametag if true
* `entity_disable_gravity` (true): disable gravity for the spawned entities if true
Expand All @@ -65,6 +68,7 @@ Configures what part of your world to expose:
Configures the NetCraft web client:

* `y_offset` (20): height to shift the web client blocks upwards, to distinguish from the pre-generated landscape
* `allow_anonymous` (true): allow web users to connect without logging in, otherwise a player must first run `/websandbox auth` and click the link
* `allow_break_place_blocks` (true): allow web users to break/place blocks, set to false for view-only (see also `allow_signs`)
* `unbreakable_blocks` (`BEDROCK`): list of block types to deny the client from breaking or placing
* `allow_signs` (true): allow web users to place signs (by typing backquote followed by the text)
Expand All @@ -88,10 +92,13 @@ texture pack compatibility, see [NetCraft#textures](https://github.com/satoshinm

## Commands

* `/websandbox`: show help
* `/websandbox` or `/websandbox help`: show help
* `/websandbox list [verbose]`: list all web users connected
* `/websandbox tp [<user>]`: teleport to given web username, or web spawn location
* `/websandbox kick <user>`: disconnect given web username
* `/websandbox auth [<user>]`: generates an a web link to allow the player to authenticate over the web as themselves instead of anonymously

All commands except help and auth require op, or a `websandbox.command.<command>` permission node.

## Compatibility

Expand Down
5 changes: 5 additions & 0 deletions src/main/java/io/github/satoshinm/WebSandboxMC/Settings.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
abstract public class Settings {
// User configurable settings
public int httpPort = 4081;
public String publicURL = "http://localhost:" + httpPort + "/";
public boolean takeover = false;
public String unbindMethod = "console.getServerConnection.b";

Expand All @@ -31,9 +32,13 @@ abstract public class Settings {
// of this radius, +/-
public int radius = 16;

public boolean clickableLinks = true;
public boolean clickableLinksTellraw = false;

// raised this amount in the web world, so it is clearly distinguished from the client-generated terrain
public int y_offset = 20;

public boolean allowAnonymous = true;
public boolean allowBreakPlaceBlocks = true;
public List<String> unbreakableBlocks = new ArrayList<String>();
public boolean allowSigns = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,24 @@
import io.github.satoshinm.WebSandboxMC.Settings;
import io.github.satoshinm.WebSandboxMC.ws.WebSocketServerThread;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelId;
import net.md_5.bungee.api.chat.ClickEvent;
import net.md_5.bungee.api.chat.HoverEvent;
import net.md_5.bungee.api.chat.TextComponent;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.command.CommandSender;
import org.bukkit.command.ConsoleCommandSender;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.entity.Sheep;
import org.bukkit.event.entity.EntityDamageEvent;
import org.json.simple.JSONObject;

import java.math.BigInteger;
import java.security.SecureRandom;
import java.util.HashMap;
import java.util.Map;
import java.util.logging.Level;
Expand All @@ -27,6 +38,12 @@ public class WebPlayerBridge {
public Map<Integer, String> entityId2Username;
public Map<String, Channel> name2channel;

private Map<String, String> playerAuthKeys = new HashMap<String, String>();
private boolean clickableLinks;
private boolean clickableLinksTellraw;
private String publicURL;

private boolean allowAnonymous;
private boolean setCustomNames;
private boolean disableGravity;
private boolean disableAI;
Expand Down Expand Up @@ -58,16 +75,36 @@ public WebPlayerBridge(WebSocketServerThread webSocketServerThread, Settings set
this.constrainToSandbox = settings.entityMoveSandbox;
this.dieDisconnect = settings.entityDieDisconnect;

this.clickableLinks = settings.clickableLinks;
this.clickableLinksTellraw = settings.clickableLinksTellraw;
this.publicURL = settings.publicURL;

this.allowAnonymous = settings.allowAnonymous;
this.lastPlayerID = 0;
this.channelId2name = new HashMap<ChannelId, String>();
this.channelId2Entity = new HashMap<ChannelId, Entity>();
this.entityId2Username = new HashMap<Integer, String>();
this.name2channel = new HashMap<String, Channel>();
}

public String newPlayer(final Channel channel) {
int theirID = ++this.lastPlayerID;
final String theirName = "webguest" + theirID;
public boolean newPlayer(final Channel channel, String proposedUsername, String token) {
String theirName;
if (validateClientAuthKey(proposedUsername, token)) {
theirName = proposedUsername;
// TODO: more features when logging in as an authenticated user: move to their last spawn?
} else {
if (!proposedUsername.equals("")) { // blank = anonymous
webSocketServerThread.sendLine(channel, "T,Failed to login as "+proposedUsername);
}

if (!allowAnonymous) {
webSocketServerThread.sendLine(channel,"T,This server requires authentication.");
return false;
}

int theirID = ++this.lastPlayerID;
theirName = "webguest" + theirID;
}

this.channelId2name.put(channel.id(), theirName);
this.name2channel.put(theirName, channel);
Expand All @@ -83,7 +120,7 @@ public String newPlayer(final Channel channel) {
entity.setCustomNameVisible(true);
}
if (disableGravity) {
entity.setGravity(false); // allow flying TODO: this doesn't seem to work on Glowstone? drops like a rock. update: known bug: https://github.com/GlowstoneMC/Glowstone/issues/454
entity.setGravity(false); // allow flying
}
if (disableAI) {
if (entity instanceof LivingEntity) {
Expand All @@ -101,8 +138,7 @@ public String newPlayer(final Channel channel) {

// TODO: should this go to Bukkit chat, too/instead? make configurable?
webSocketServerThread.broadcastLine("T," + theirName + " has joined.");

return theirName;
return true;
}

public void clientMoved(final Channel channel, final double x, final double y, final double z, final double rx, final double ry) {
Expand Down Expand Up @@ -193,4 +229,63 @@ public void notifySheared(String username, String playerName) {
webSocketServerThread.sendLine(channel, "T,You were sheared by " + playerName);
}
}

private final SecureRandom random = new SecureRandom();

public void newClientAuthKey(String username, CommandSender sender) {
String token = new BigInteger(130, random).toString(32);

playerAuthKeys.put(username, token);
// TODO: persist to disk


String url = publicURL + "#++" + username + "+" + token;

if (clickableLinks && sender instanceof Player) {
Player player = (Player) sender;

String linkText = "Click here to login";
String hoverText = "Login to the web sandbox as " + player.getName();

// There are two strategies since TextComponents fails with on Glowstone with an error:
// java.lang.UnsupportedOperationException: Not supported yet.
// at org.bukkit.entity.Player$Spigot.sendMessage(Player.java:1734)
// see https://github.com/GlowstoneMC/Glowkit-Legacy/pull/8
if (clickableLinksTellraw) {
JSONObject json = new JSONObject();
json.put("text", linkText);
json.put("bold", true);

JSONObject clickEventJson = new JSONObject();
clickEventJson.put("action", "open_url");
clickEventJson.put("value", url);
json.put("clickEvent", clickEventJson);

JSONObject hoverEventJson = new JSONObject();
hoverEventJson.put("action", "show_text");
JSONObject hoverTextObject = new JSONObject();
hoverTextObject.put("text", hoverText);
hoverEventJson.put("value", hoverTextObject);
json.put("hoverEvent", hoverEventJson);

Bukkit.getServer().dispatchCommand(Bukkit.getConsoleSender(), "tellraw " + player.getName() + " " + json.toJSONString());
} else {
TextComponent message = new TextComponent(linkText);
message.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, url));
message.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new TextComponent[] { new TextComponent(hoverText) }));
message.setBold(true);

player.spigot().sendMessage(message);
}
} else {
sender.sendMessage("Visit this URL to login: " + url);
}
}

private boolean validateClientAuthKey(String username, String token) {
String expected = playerAuthKeys.get(username);
if (expected == null) return false;
return expected.equals(token);
// TODO: load from disk
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public SettingsBukkit(Plugin plugin) {
config.options().copyDefaults(true);

config.addDefault("http.port", this.httpPort);
config.addDefault("http.publicURL", this.publicURL);
config.addDefault("http.takeover", this.takeover);
config.addDefault("http.unbind_method", this.unbindMethod);

Expand All @@ -37,8 +38,11 @@ public SettingsBukkit(Plugin plugin) {
config.addDefault("mc.y_center", this.y_center);
config.addDefault("mc.z_center", this.z_center);
config.addDefault("mc.radius", this.radius);
config.addDefault("mc.clickable_links", this.clickableLinks);
config.addDefault("mc.clickable_links_tellraw", this.clickableLinksTellraw);

config.addDefault("nc.y_offset", this.y_offset);
config.addDefault("nc.allow_anonymous", this.allowAnonymous);
config.addDefault("nc.allow_break_place_blocks", this.allowBreakPlaceBlocks);
this.unbreakableBlocks.add("BEDROCK");
config.addDefault("nc.unbreakable_blocks", this.unbreakableBlocks);
Expand All @@ -51,6 +55,7 @@ public SettingsBukkit(Plugin plugin) {
config.addDefault("nc.warn_missing_blocks_to_web", this.warnMissing);

this.httpPort = plugin.getConfig().getInt("http.port");
this.publicURL = plugin.getConfig().getString("http.publicURL");
this.takeover = plugin.getConfig().getBoolean("http.takeover");
this.unbindMethod = plugin.getConfig().getString("http.unbind_method");

Expand All @@ -70,8 +75,12 @@ public SettingsBukkit(Plugin plugin) {
this.z_center = plugin.getConfig().getInt("mc.z_center");
this.radius = plugin.getConfig().getInt("mc.radius");

this.clickableLinks = plugin.getConfig().getBoolean("mc.clickable_links");
this.clickableLinksTellraw = plugin.getConfig().getBoolean("mc.clickable_links_tellraw");

this.y_offset = plugin.getConfig().getInt("nc.y_offset");

this.allowAnonymous = plugin.getConfig().getBoolean("nc.allow_anonymous");
this.allowBreakPlaceBlocks = plugin.getConfig().getBoolean("nc.allow_break_place_blocks");
this.unbreakableBlocks = plugin.getConfig().getStringList("nc.unbreakable_blocks");
this.allowSigns = plugin.getConfig().getBoolean("nc.allow_signs");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

import io.github.satoshinm.WebSandboxMC.ws.WebSocketServerThread;
import io.netty.channel.Channel;
import net.md_5.bungee.api.chat.ClickEvent;
import net.md_5.bungee.api.chat.TextComponent;
import org.bukkit.Location;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.ConsoleCommandSender;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;

Expand All @@ -26,8 +29,8 @@ public boolean onCommand(CommandSender sender, Command command, String label, St
if (sender instanceof Player) {
Player player = (Player) sender;
if (!usePermissions) {
if (!player.isOp()) {
sender.sendMessage("/websandbox requires op");
if (!player.isOp() && !subcommand.equals("auth") && !subcommand.equals("help")) {
sender.sendMessage("This /websandbox subcommand requires op");
return true;
}
} else {
Expand Down Expand Up @@ -116,14 +119,33 @@ public boolean onCommand(CommandSender sender, Command command, String label, St
return true;
}

sender.sendMessage("Kicking web client "+name);
webSocketServerThread.sendLine(channel,"T,You were kicked by "+sender.getName());
sender.sendMessage("Kicking web client " + name);
webSocketServerThread.sendLine(channel, "T,You were kicked by " + sender.getName());
webSocketServerThread.webPlayerBridge.clientDisconnected(channel);
return true;
} else if (subcommand.equals("auth")) {
// TODO: non-ops should be able to run this command by default
String name;

if (!(sender instanceof Player)) {
if (split.length < 2) {
sender.sendMessage("Usage: /websandbox auth <user>");
return true;
}
name = split[1];
} else {
Player player = (Player) sender;
name = player.getName();
}

webSocketServerThread.webPlayerBridge.newClientAuthKey(name, sender);

return true;
} else { // help
sender.sendMessage("/websandbox list [verbose] -- list all web users connected");
sender.sendMessage("/websandbox tp [<user>] -- teleport to given web username, or web spawn location");
sender.sendMessage("/websandbox kick <user> -- disconnect given web username");
sender.sendMessage("/websandbox auth [<user>] -- get authentication token to login non-anonymously");
// TODO: reload, reconfig commands
}
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,25 +31,6 @@ public WebSocketFrameHandler(WebSocketServerThread webSocketServerThread) {
this.webSocketServerThread = webSocketServerThread;
}

@Override
@SuppressWarnings("deprecation") // TODO: why is HANDSHAKE_COMPLETE deprecated and what is the replacement?
public void userEventTriggered(final ChannelHandlerContext ctx, Object evt) throws Exception {
webSocketServerThread.log(Level.FINEST, "userEventTriggered: "+evt);
if (evt == WebSocketServerProtocolHandler.ServerHandshakeStateEvent.HANDSHAKE_COMPLETE) {
// "The Handshake was complete successful and so the channel was upgraded to websockets"

// Since we're in a callback we cannot call any Bukkit API safely here, see:
// http://bukkit.gamepedia.com/Scheduler_Programming#Tips_for_thread_safety
// " Warning: Asynchronous tasks should never access any API in Bukkit"
webSocketServerThread.scheduleSyncTask(new Runnable() {
@Override
public void run() {
webSocketServerThread.handleNewClient(ctx);
}
});
}
}

@Override
public void channelRead0(final ChannelHandlerContext ctx, WebSocketFrame frame) throws Exception {
webSocketServerThread.log(Level.FINEST, "channel read, frame="+frame);
Expand Down
Loading