implement simple authentication and add redirects

This commit is contained in:
hippoz 2021-11-08 00:25:27 +02:00
parent 1626cd99e5
commit 574359bd2e
No known key found for this signature in database
GPG key ID: 7C52899193467641
12 changed files with 346 additions and 38 deletions

View file

@ -1,22 +1,67 @@
import sys
from asyncio import wait_for, TimeoutError
from base64 import b64encode
from sanic import Sanic from sanic import Sanic
from sanic.response import file from sanic.response import file, redirect
from InputController import InputController 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.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") @app.websocket("/gateway")
async def gateway(req, ws): 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: while True:
app.ctx.input_controller.process_message(await ws.recv()) input_controller.process_message(await ws.recv())
def main(): 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) app.run(host='0.0.0.0', port=4003, access_log=False)
if __name__ == "__main__": if __name__ == "__main__":

70
frontend/src/App.js Normal file
View file

@ -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;

34
frontend/src/Banner.js Normal file
View file

@ -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(`
<div class="card small-card center-text">
<h2>Login</h2>
<p>You need to enter the login code before you can start controlling your device.</p>
<br>
<div class="full-width">
<input id="code-input" class="input full-width" placeholder="Code">
<br>
<button id="continue-button" class="button-default full-width">Continue</button>
</div>
</div>
`).children[0];
this.element.querySelector("#continue-button").addEventListener("click", () => {
if (this.onPasswordSubmitted)
this.onPasswordSubmitted(this.element.querySelector("#code-input").value);
});
target.appendChild(this.element);
}
}

View file

@ -6,16 +6,37 @@ class Connection {
this.log = Logger(["Connection"], ["log"]).log; this.log = Logger(["Connection"], ["log"]).log;
this.messageLog = Logger(["Connection", "Message"], ["log"]).log; this.messageLog = Logger(["Connection", "Message"], ["log"]).log;
this.url = url; this.url = url;
this.isReady = false;
} }
connect() { formatAuthString(password) {
return `%auth%${btoa(password)}`;
}
connect(password) {
this.ws = new WebSocket(this.url); this.ws = new WebSocket(this.url);
this.ws.onerror = (e) => this.log("Error", e); this.ws.onerror = (e) => this.log("Error", e);
this.ws.onopen = () => { this.ws.onopen = () => {
this.log("Open"); 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.log("Closed - attempting to reconnect in 4000ms");
this.isReady = false;
setTimeout(() => this.connect(), 4000); setTimeout(() => this.connect(), 4000);
} }
} }

View file

@ -4,14 +4,22 @@ class KeyboardController {
constructor(connection) { constructor(connection) {
this.connection = connection; this.connection = connection;
this.keyboard = null; this.keyboard = null;
this.keyboardDiv = null;
} }
_sendKeyPress(l) { _sendKeyPress(l) {
this.connection.sendMessage("k", [l]); this.connection.sendMessage("k", [l]);
} }
bindTo(element) { mountOn(element) {
this.keyboard = new SimpleKeyboard(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), onKeyPress: this.onKeyPress.bind(this),
mergeDisplay: true, mergeDisplay: true,
layoutName: "default", 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) { onKeyPress(button) {
if (button === "{shift}" || button === "{lock}") if (button === "{shift}" || button === "{lock}")
return this.handleShift(); return this.handleShift();

View file

@ -0,0 +1,7 @@
export function getAuth() {
return localStorage.getItem("$auth");
}
export function setAuth(newValue) {
return localStorage.setItem("$auth", newValue);
}

View file

@ -0,0 +1,40 @@
class LoginPrompt {
constructor() {
this.element = null;
}
mountOn(target) {
if (this.element)
return; // Already mounted
this.element = document.createRange().createContextualFragment(`
<div class="card small-card center-text">
<h2>Login</h2>
<p>You need to enter the login code before you can start controlling your device.</p>
<br>
<div class="full-width">
<input type="password" id="code-input" class="input full-width" placeholder="Code">
<br>
<button id="continue-button" class="button-default full-width">Continue</button>
</div>
</div>
`).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;

View file

@ -12,6 +12,7 @@ class TouchpadController {
this.isInHoldingMode = false; this.isInHoldingMode = false;
this.ongoingTouches = {}; this.ongoingTouches = {};
this.holdModeTimeout = null; this.holdModeTimeout = null;
this.touchpadDiv = null;
this.connection = connection; this.connection = connection;
} }
@ -59,10 +60,25 @@ class TouchpadController {
this.connection.sendMessage("c", [this.getButtonCode(button)]); this.connection.sendMessage("c", [this.getButtonCode(button)]);
} }
bindTo(element) { mountOn(element) {
element.addEventListener("touchmove", this.onTouchMove.bind(this)); if (this.touchpadDiv)
element.addEventListener("touchend", this.onTouchEnd.bind(this)); return; // Already mounted
element.addEventListener("touchstart", this.onTouchStart.bind(this));
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) { onTouchMove(event) {

View file

@ -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 "simple-keyboard/build/css/index.css";
import "./styles/main.css"; import "./styles/main.css";
import App from "./App";
window.onerror = (error) => { new App(document.body);
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);

View file

@ -23,6 +23,87 @@ body {
padding: 8px; 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 { .touchpad {
height: clamp(5rem, 30rem, 55vh); height: clamp(5rem, 30rem, 55vh);
width: 100%; width: 100%;

View file

@ -14,6 +14,7 @@ module.exports = {
plugins: [ plugins: [
new HtmlWebpackPlugin({ new HtmlWebpackPlugin({
title: "Capybara", title: "Capybara",
filename: "app.html"
}), }),
new MiniCssExtractPlugin({ new MiniCssExtractPlugin({
filename: "[name].[contenthash].css" filename: "[name].[contenthash].css"