diff --git a/public/index.html b/public/index.html
index 9c8ad46..10c7b70 100644
--- a/public/index.html
+++ b/public/index.html
@@ -9,10 +9,10 @@
-
diff --git a/public/mobile.html b/public/mobile.html
index 17eb887..a139656 100644
--- a/public/mobile.html
+++ b/public/mobile.html
@@ -10,10 +10,10 @@
-
diff --git a/src/server.js b/src/server.js
index b3af4ef..078ae65 100644
--- a/src/server.js
+++ b/src/server.js
@@ -76,6 +76,15 @@ const server = http.createServer(async (request, response) => {
return sendJson(response, 200, { enabled: true, token: ACCESS_TOKEN });
}
+ if (url.pathname === "/auth/login" && request.method === "POST") {
+ const body = await readFormBody(request);
+ if (!ACCESS_PASSWORD || timingSafeEqualText(String(body.password || ""), ACCESS_PASSWORD)) {
+ if (ACCESS_PASSWORD) response.setHeader("set-cookie", cookieHeader(ACCESS_TOKEN));
+ return sendRedirect(response, body.next || "/");
+ }
+ return sendRedirect(response, "/?auth_error=1");
+ }
+
if (url.pathname.startsWith("/api/") && !isAuthorizedRequest(request, url)) {
return sendAuthRequired(response);
}
@@ -841,6 +850,14 @@ async function readJsonBody(request) {
return JSON.parse(content);
}
+async function readFormBody(request) {
+ const chunks = [];
+ for await (const chunk of request) chunks.push(chunk);
+ const content = Buffer.concat(chunks).toString("utf8");
+ const params = new URLSearchParams(content);
+ return Object.fromEntries(params.entries());
+}
+
async function readImageUploadBody(request) {
const body = await readJsonBody(request);
const type = String(body.type || "").toLowerCase();
@@ -916,6 +933,12 @@ function sendText(response, status, text, type) {
response.end(text);
}
+function sendRedirect(response, location) {
+ const safeLocation = String(location || "/").startsWith("/") ? String(location || "/") : "/";
+ response.writeHead(303, { location: safeLocation });
+ response.end();
+}
+
function contentType(filePath) {
const ext = path.extname(filePath).toLowerCase();
return {
diff --git a/test/access-password.test.js b/test/access-password.test.js
index 49e83b3..3d00a55 100644
--- a/test/access-password.test.js
+++ b/test/access-password.test.js
@@ -15,6 +15,9 @@ test("server supports optional shared access password authentication", () => {
assert.match(server, /HOTNESS_ACCESS_PASSWORD/);
assert.match(server, /\/api\/auth\/status/);
assert.match(server, /\/api\/auth\/login/);
+ assert.match(server, /\/auth\/login/);
+ assert.match(server, /readFormBody/);
+ assert.match(server, /sendRedirect/);
assert.match(server, /isAuthorizedRequest/);
assert.match(server, /sendAuthRequired/);
assert.match(server, /x-hotness-auth-token/i);
@@ -22,6 +25,9 @@ test("server supports optional shared access password authentication", () => {
test("desktop page has a password gate and sends auth token with API calls", () => {
assert.match(desktopHtml, /id="auth-gate"/);
+ assert.match(desktopHtml, /action="\/auth\/login"/);
+ assert.match(desktopHtml, /method="post"/);
+ assert.match(desktopHtml, /name="password"/);
assert.match(desktopHtml, /id="auth-password"/);
assert.match(desktopJs, /HOTNESS_AUTH_TOKEN_KEY/);
assert.match(desktopJs, /ensureAccessAuth/);
@@ -43,6 +49,9 @@ test("desktop login submit is bound before the rest of the app can fail", () =>
test("mobile page has the same password gate for cloud use", () => {
assert.match(mobileHtml, /id="auth-gate"/);
+ assert.match(mobileHtml, /action="\/auth\/login"/);
+ assert.match(mobileHtml, /method="post"/);
+ assert.match(mobileHtml, /name="password"/);
assert.match(mobileHtml, /id="auth-password"/);
assert.match(mobileJs, /HOTNESS_AUTH_TOKEN_KEY/);
assert.match(mobileJs, /ensureAccessAuth/);