Compare commits

...

2 Commits

Author SHA1 Message Date
James Pace 28fc4e0f68 Make a separate login page and protect assets. 2026-08-26 12:06:40 -04:00
James Pace 01523517d2 Require login for video streams. 2026-08-26 14:16:58 +00:00
8 changed files with 18493 additions and 7 deletions

View File

@ -14,7 +14,7 @@ install(DIRECTORY
# Install launch and param files.
install(DIRECTORY
launch params
launch params assets
DESTINATION share/${PROJECT_NAME}/
)

View File

@ -12,18 +12,22 @@ from aiohttp import web
import asyncio
from ament_index_python import get_package_share_directory
from am_i_up.Authenticator import Authenticator
from am_i_up.MediaMTXAuthenticator import MediaMTXAuthenticator
import rclpy
class Api:
def __init__(self, facts):
self._facts = facts
self._authenticator = Authenticator(facts.get_password(), facts.get_open_endpoints())
self._authenticator = Authenticator(facts.get_password(), self.get_open_endpoints())
self._mediamtx_authenticator = MediaMTXAuthenticator()
async def run(self):
ui_share_directory = get_package_share_directory(self._facts.get_ui_pkg())
ui_static_directory = ui_share_directory + "/dist/assets"
server_static_directory = get_package_share_directory("am_i_up") + "/assets"
app = web.Application(middlewares=[self._authenticator.build_middleware()])
app.add_routes([
web.get('/api/ping', self.ping),
@ -34,10 +38,15 @@ class Api:
web.get("/api/position", self.position),
web.get("/api/diagnostics", self.diagnostics),
web.post("/api/login", self.login),
web.post("/api/mediamtx/auth", self.mediamtx_auth),
web.get("/api/mediamtx/login", self.mediamtx_login),
web.static("/assets", ui_static_directory),
web.static("/server_assets", server_static_directory),
web.get('/login', self.login_page),
# we're not actually using key anywhere, but doing this allows react router
# to work correctly.
web.get("/{key:.*}", self.index)
web.get("/{key}", self.index),
web.get("/", self.index)
])
url = "0.0.0.0"
@ -53,6 +62,45 @@ class Api:
await asyncio.sleep(3600)
await runner.cleanup()
def get_open_endpoints(self):
return ["/login", "/api/login", "/server_assets/(.*)", "/api/mediamtx/auth"]
async def login_page(self, request):
login_page = get_package_share_directory("am_i_up") + "/assets/login.html"
return web.FileResponse(login_page)
async def mediamtx_auth(self, request):
# Take the mediamtx json and figure out if I'm a valid user.
# MediaMTX json looks like:
# {
# "user": "user",
# "password": "password",
# "token": "token",
# "ip": "ip",
# "action": "publish|read|playback|api|metrics|pprof",
# "path": "path",
# "protocol": "rtsp|rtmp|hls|webrtc|srt",
# "id": "id",
# "query": "query",
# "userAgent": "userAgent"
# }
# This route will be unprotected.
request_dict = await request.json()
print("Got request: {}", request_dict)
# Only allowing reads for now.
if request_dict["action"] != "read":
return web.Response(status=401)
valid_token = self._mediamtx_authenticator.confirm_token(request_dict["token"])
if valid_token:
return web.Response(status=200)
return web.Response(status=401)
async def mediamtx_login(self, request):
# Generate a valid token for the mediamtx stuff to use to login
# with.
token = self._mediamtx_authenticator.make_token()
resp = {"token": token}
return web.json_response(resp)
async def login(self, request):
request_dict = await request.json()

View File

@ -119,10 +119,6 @@ class Facts:
def get_password(self):
return self._password
def get_open_endpoints(self):
# Hardcoding for now, can make a parameter later.
return ["/login", "/api/login", "/assets/(.*)"]
def _status_callback(self, msg):
self._status_string = msg.data

View File

@ -0,0 +1,25 @@
from datetime import datetime, timedelta
import secrets
class MediaMTXAuthenticator():
def __init__(self):
# Type: Dict["token"] -> expiration time
self._valid_tokens = {}
def confirm_token(self, token):
if not token in self._valid_tokens.keys():
return False
token_expiration_time = self._valid_tokens[token]
now = datetime.now()
if now < token_expiration_time:
return True
return False
def make_token(self):
token = secrets.token_urlsafe(32)
expiration_time = datetime.now() + timedelta(minutes=1)
self._valid_tokens[token] = expiration_time
return token

6312
assets/bootstrap.bundle.js vendored Normal file

File diff suppressed because it is too large Load Diff

12048
assets/bootstrap.css vendored Normal file

File diff suppressed because it is too large Load Diff

54
assets/login.html Normal file
View File

@ -0,0 +1,54 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bootstrap HTML Example</title>
<link href="server_assets/bootstrap.css" rel="stylesheet">
<style>
.vert-padded {
padding-top: 10px;
padding-bottom: 10px;
}
</style>
</head>
<body>
<div>
<div class="container vert-padded">
<form id="loginForm">
<div class="mb-3">
<label for="passwordForm" class="form-label">Password</label>
<input name="password" type="password" class="form-control" id="passwordForm" />
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
</div>
<script>
console.log("Hello");
document.getElementById('loginForm').addEventListener('submit', async (event) => {
console.log("submit");
event.preventDefault();
console.log(event.target);
const formEntries = new FormData(event.target);
const formData = Object.fromEntries(formEntries);
console.log(formData);
const resp = await fetch("/api/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
if (resp.redirected) {
window.location.assign("/");
}
});
</script>
<script src="server_assets/bootstrap.bundle.js"</script>
</body>
</html>

View File

@ -1,3 +1,6 @@
authMethod: http
authHTTPAddress: http://127.0.0.1:8000/api/mediamtx/auth
authHTTPExclude: []
paths:
image:
source: rtsp://127.0.0.1:8559/image