From 574359bd2efaae07f84f41ff2f5e7e96927102d3 Mon Sep 17 00:00:00 2001 From: hippoz Date: Mon, 8 Nov 2021 00:25:27 +0200 Subject: [PATCH] implement simple authentication and add redirects --- InputController.py | 2 +- capybara.py | 53 +++++++++++++++++-- frontend/src/App.js | 70 ++++++++++++++++++++++++++ frontend/src/Banner.js | 34 +++++++++++++ frontend/src/Connection.js | 25 ++++++++- frontend/src/Keyboard.js | 20 +++++++- frontend/src/LocalConfiguration.js | 7 +++ frontend/src/LoginPrompt.js | 40 +++++++++++++++ frontend/src/Touchpad.js | 24 +++++++-- frontend/src/index.js | 27 +--------- frontend/src/styles/main.css | 81 ++++++++++++++++++++++++++++++ frontend/webpack.config.js | 1 + 12 files changed, 346 insertions(+), 38 deletions(-) create mode 100644 frontend/src/App.js create mode 100644 frontend/src/Banner.js create mode 100644 frontend/src/LocalConfiguration.js create mode 100644 frontend/src/LoginPrompt.js diff --git a/InputController.py b/InputController.py index 7ababb9..d580d62 100644 --- a/InputController.py +++ b/InputController.py @@ -77,4 +77,4 @@ class InputController(): print("got invalid code from parser (is this a bug with the MessageParser?)") return False - return True \ No newline at end of file + return True diff --git a/capybara.py b/capybara.py index 7709e28..97805c4 100755 --- a/capybara.py +++ b/capybara.py @@ -1,22 +1,67 @@ +import sys +from asyncio import wait_for, TimeoutError +from base64 import b64encode from sanic import Sanic -from sanic.response import file +from sanic.response import file, redirect from InputController import InputController +from MessageParser import MessageParser -app = Sanic("capybara") +app = Sanic( + "capybara", + load_env="CAPYBARA_" +) +input_controller = InputController() +control_message_parser = MessageParser() + +# auth packet +control_message_parser.add_handler("0", { + "auth_string": "str" +}) -app.ctx.input_controller = InputController() app.static("app", "frontend/dist/", resource_type="dir") +@app.get("/") +async def home(req): + return redirect("/app/app.html") + +@app.get("/app") +async def app_route(req): + return redirect("/app/app.html") @app.websocket("/gateway") async def gateway(req, ws): + # Before the client is able to send any data, we must await an authorization packet + try: + auth_payload = await wait_for(ws.recv(), timeout=5) + except TimeoutError: + await ws.close(code=4001, reason="Invalid auth packet") + return + + code, args = control_message_parser.parse(auth_payload) + if (not code or + code != "0" or + args["auth_string"] != app.ctx.EXPECTED_AUTH_STRING): # 0 is the code for the initial auth packet + await ws.close(code=4001, reason="Invalid auth packet") + return + + await ws.send("1") # send a single `1` to let the client know the server is input packets + while True: - app.ctx.input_controller.process_message(await ws.recv()) + input_controller.process_message(await ws.recv()) def main(): + if not app.config.get("AUTH_PASSWORD"): + print( + "capybara: FATAL ERROR: Capybara is expecting the `CAPYBARA_AUTH_PASSWORD` environment variable to be set (to a password of your choice).\nYour users will use this password to authenticate to your server.\nEXITING...", + file=sys.stderr + ) + exit(1) + return + + app.ctx.EXPECTED_AUTH_STRING = "%auth%" + str(b64encode(app.config.AUTH_PASSWORD.encode("utf-8")), "utf-8") app.run(host='0.0.0.0', port=4003, access_log=False) if __name__ == "__main__": diff --git a/frontend/src/App.js b/frontend/src/App.js new file mode 100644 index 0000000..49473a4 --- /dev/null +++ b/frontend/src/App.js @@ -0,0 +1,70 @@ +import Connection from "./Connection"; +import KeyboardController from "./Keyboard"; +import { getAuth, setAuth } from "./LocalConfiguration"; +import LoginPrompt from "./LoginPrompt"; +import TouchpadController from "./Touchpad"; + +class App { + constructor(mountElement) { + this.mountElement = mountElement; + + this.connection = new Connection(`ws://${location.host}/gateway`); + this.touchpad = new TouchpadController(this.connection); + this.keyboard = new KeyboardController(this.connection); + this.loginPromptComponent = null; + + this.connection.connect(getAuth()); + this.connection.ws.addEventListener("close", ({code}) => { + this.unmountApp(); + if (code === 4001) { // 4001 - code for bad auth + this.transitionTo("login"); + } + }); + this.connection.onHandshakeCompleted = () => { + this.transitionTo("app"); + }; + } + + transitionTo(type) { + switch (type) { + case "login": + this.unmountApp(); + this.mountLoginComponent(); + break; + case "app": + this.unmountLoginComponent(); + this.mountApp(); + break; + default: + throw new Error(`transitionTo type ${type} is invalid`); + } + } + + mountLoginComponent() { + if (!this.loginPromptComponent) + this.loginPromptComponent = new LoginPrompt(this.connection); + + this.loginPromptComponent.mountOn(this.mountElement); + this.loginPromptComponent.onPasswordSubmitted = p => { + setAuth(p); + this.connection.connect(p); + }; + } + + unmountLoginComponent() { + if (this.loginPromptComponent) + this.loginPromptComponent.unmount(); + } + + mountApp() { + this.touchpad.mountOn(this.mountElement); + this.keyboard.mountOn(this.mountElement); + } + + unmountApp() { + this.touchpad.unmount(); + this.keyboard.unmount(); + } +} + +export default App; diff --git a/frontend/src/Banner.js b/frontend/src/Banner.js new file mode 100644 index 0000000..7395654 --- /dev/null +++ b/frontend/src/Banner.js @@ -0,0 +1,34 @@ +class Banner { + constructor() { + this.text = null; + } + + updateText(text) { + this.text = text; + } + + mountOn(target) { + if (this.element) + return; // Already mounted + + this.element = document.createRange().createContextualFragment(` +
+

Login

+

You need to enter the login code before you can start controlling your device.

+
+
+ +
+ +
+
+ `).children[0]; + + this.element.querySelector("#continue-button").addEventListener("click", () => { + if (this.onPasswordSubmitted) + this.onPasswordSubmitted(this.element.querySelector("#code-input").value); + }); + + target.appendChild(this.element); + } +} \ No newline at end of file diff --git a/frontend/src/Connection.js b/frontend/src/Connection.js index e08e6bc..ab74b0c 100644 --- a/frontend/src/Connection.js +++ b/frontend/src/Connection.js @@ -6,16 +6,37 @@ class Connection { this.log = Logger(["Connection"], ["log"]).log; this.messageLog = Logger(["Connection", "Message"], ["log"]).log; this.url = url; + this.isReady = false; } - connect() { + formatAuthString(password) { + return `%auth%${btoa(password)}`; + } + + connect(password) { this.ws = new WebSocket(this.url); this.ws.onerror = (e) => this.log("Error", e); this.ws.onopen = () => { this.log("Open"); + this.log("Sending authentication packet"); + this.ws.send(`0${this.formatAuthString(password)}`); // send auth packet }; - this.ws.onclose = () => { + this.ws.onmessage = ({ data }) => { + if (data === "1") { + if (this.onHandshakeCompleted) + this.onHandshakeCompleted(); + + this.isReady = true; + this.log("Handshake complete"); + } + }; + this.ws.onclose = ({ code }) => { + if (code === 4001) {// code for bad auth + this.log("Closed due to bad auth - skipping reconnect"); + return; + } this.log("Closed - attempting to reconnect in 4000ms"); + this.isReady = false; setTimeout(() => this.connect(), 4000); } } diff --git a/frontend/src/Keyboard.js b/frontend/src/Keyboard.js index dec22b8..32435a9 100644 --- a/frontend/src/Keyboard.js +++ b/frontend/src/Keyboard.js @@ -4,14 +4,22 @@ class KeyboardController { constructor(connection) { this.connection = connection; this.keyboard = null; + this.keyboardDiv = null; } _sendKeyPress(l) { this.connection.sendMessage("k", [l]); } - bindTo(element) { - this.keyboard = new SimpleKeyboard(element, { + mountOn(element) { + if (this.keyboardDiv) + return; // Already mounted; + + this.keyboardDiv = document.createElement("div"); + this.keyboardDiv.classList.add("keyboard"); + element.appendChild(this.keyboardDiv); + + this.keyboard = new SimpleKeyboard(this.keyboardDiv, { onKeyPress: this.onKeyPress.bind(this), mergeDisplay: true, layoutName: "default", @@ -50,6 +58,14 @@ class KeyboardController { }); } + unmount() { + if (!this.keyboardDiv) + return; // Not mounted - don't do anything + + this.keyboardDiv.parentElement.removeChild(this.keyboardDiv); + this.keyboardDiv = null; + } + onKeyPress(button) { if (button === "{shift}" || button === "{lock}") return this.handleShift(); diff --git a/frontend/src/LocalConfiguration.js b/frontend/src/LocalConfiguration.js new file mode 100644 index 0000000..5911700 --- /dev/null +++ b/frontend/src/LocalConfiguration.js @@ -0,0 +1,7 @@ +export function getAuth() { + return localStorage.getItem("$auth"); +} + +export function setAuth(newValue) { + return localStorage.setItem("$auth", newValue); +} diff --git a/frontend/src/LoginPrompt.js b/frontend/src/LoginPrompt.js new file mode 100644 index 0000000..72ab2c9 --- /dev/null +++ b/frontend/src/LoginPrompt.js @@ -0,0 +1,40 @@ +class LoginPrompt { + constructor() { + this.element = null; + } + + mountOn(target) { + if (this.element) + return; // Already mounted + + this.element = document.createRange().createContextualFragment(` +
+

Login

+

You need to enter the login code before you can start controlling your device.

+
+
+ +
+ +
+
+ `).children[0]; + + this.element.querySelector("#continue-button").addEventListener("click", () => { + if (this.onPasswordSubmitted) + this.onPasswordSubmitted(this.element.querySelector("#code-input").value); + }); + + target.appendChild(this.element); + } + + unmount() { + if (!this.element) + return; // Already unmounted + + this.element.parentElement.removeChild(this.element); + this.element = null; + } +} + +export default LoginPrompt; diff --git a/frontend/src/Touchpad.js b/frontend/src/Touchpad.js index d56fcfd..c7229b3 100644 --- a/frontend/src/Touchpad.js +++ b/frontend/src/Touchpad.js @@ -12,6 +12,7 @@ class TouchpadController { this.isInHoldingMode = false; this.ongoingTouches = {}; this.holdModeTimeout = null; + this.touchpadDiv = null; this.connection = connection; } @@ -59,10 +60,25 @@ class TouchpadController { this.connection.sendMessage("c", [this.getButtonCode(button)]); } - bindTo(element) { - element.addEventListener("touchmove", this.onTouchMove.bind(this)); - element.addEventListener("touchend", this.onTouchEnd.bind(this)); - element.addEventListener("touchstart", this.onTouchStart.bind(this)); + mountOn(element) { + if (this.touchpadDiv) + return; // Already mounted + + this.touchpadDiv = document.createElement("div"); + this.touchpadDiv.classList.add("touchpad"); + element.appendChild(this.touchpadDiv); + + this.touchpadDiv.addEventListener("touchmove", this.onTouchMove.bind(this)); + this.touchpadDiv.addEventListener("touchend", this.onTouchEnd.bind(this)); + this.touchpadDiv.addEventListener("touchstart", this.onTouchStart.bind(this)); + } + + unmount() { + if (!this.touchpadDiv) + return; // Not mounted - don't do anything + + this.touchpadDiv.parentElement.removeChild(this.touchpadDiv); + this.touchpadDiv = null; } onTouchMove(event) { diff --git a/frontend/src/index.js b/frontend/src/index.js index bc13b11..7ad9d61 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -1,28 +1,5 @@ -import { domLog } from "./common/Logger"; -import Connection from "./Connection"; -import KeyboardController from "./Keyboard"; -import TouchpadController from "./Touchpad"; - import "simple-keyboard/build/css/index.css"; import "./styles/main.css"; +import App from "./App"; -window.onerror = (error) => { - domLog(`|| ERROR: ${error} ||`); -}; - -const touchpadDiv = document.createElement("div"); -touchpadDiv.classList.add("touchpad"); -document.body.appendChild(touchpadDiv); - -const keyboardDiv = document.createElement("div"); -keyboardDiv.classList.add("keyboard"); -document.body.appendChild(keyboardDiv); - -const connection = new Connection(`ws://${location.host}/gateway`); -const controller = new TouchpadController(connection); -const keyboardController = new KeyboardController(connection); - -connection.connect(); - -controller.bindTo(touchpadDiv); -keyboardController.bindTo(keyboardDiv); +new App(document.body); diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index d41bdf4..09a36ef 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -23,6 +23,87 @@ body { padding: 8px; } +.card { + margin: 6px; + padding: 8px; + background: var(--accent-bg-color); + color: #000000; + border-radius: var(--card-border-radius); +} + +.card.inner-card { + margin: 4px; + margin-bottom: 28px; + padding: 18px; + border: solid var(--accent-color) 1px; +} + +.card.layout-card { + margin: 36px auto; + padding: 26px; + width: 80%; + box-shadow: 0 0 28px 3px rgba(0, 0, 0, 0.40); +} + +.card.small-card { + margin: 36px auto; + padding: 26px; + width: 350px; + box-shadow: 0 0 28px 3px rgba(0, 0, 0, 0.40); +} + +@media screen and (max-width: 768px) { + .card.small-card { + width: 80%; + } +} + +.button-default { + display: block; + box-sizing: border-box; + padding: 12px; + margin: 4px; + text-decoration: none; + border: none; + background-color: var(--accent-bg-color); + border-radius: var(--button-border-radius); + font-size: 18px; + white-space: nowrap; + color: var(--button-accent-color); + cursor: pointer; + outline: none; + min-width: 50px; + text-align: center; +} + +.full-width { + width: 100%; +} + +.center-text { + text-align: center; +} + +.button-default:hover:not(.button-selected) { + color: var(--accent-bg-color); + background-color: var(--hover-bg-color); +} + +.button-selected { + color: var(--accent-bg-color); + background-color: var(--selected-bg-color); +} + +.input { + display: block; + box-sizing: border-box; + border: none; + outline: none; + border-radius: var(--button-border-radius); + margin: 4px; + padding: 12px; +} + .touchpad { height: clamp(5rem, 30rem, 55vh); width: 100%; diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 14743d3..307437e 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -14,6 +14,7 @@ module.exports = { plugins: [ new HtmlWebpackPlugin({ title: "Capybara", + filename: "app.html" }), new MiniCssExtractPlugin({ filename: "[name].[contenthash].css"