various improvements and fixes to PoW challenge UI and token refresh
This commit is contained in:
+33
-25
@@ -187,7 +187,7 @@ function verifyTokenRefreshable(token: string, req: express.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
req.log.info({ token }, "Allowing token refresh");
|
||||
req.log.info({ token: `...${token.slice(-5)}` }, "Allowing token refresh");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -227,51 +227,57 @@ router.post("/verify", async (req, res) => {
|
||||
const ip = req.ip;
|
||||
req.log.info("Got verification request");
|
||||
if (recentAttempts.has(ip)) {
|
||||
res
|
||||
.status(429)
|
||||
.json({ error: "Rate limited; wait a minute before trying again" });
|
||||
const error = "Rate limited; wait a minute before trying again";
|
||||
req.log.info({ error }, "Verification rejected");
|
||||
res.status(429).json({ error });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = verifySchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "Invalid verify request", details: result.error });
|
||||
const error = "Invalid verify request";
|
||||
req.log.info({ error, result }, "Verification rejected");
|
||||
res.status(400).json({ error, details: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const { challenge, signature, solution } = result.data;
|
||||
if (signMessage(challenge, powKeySalt) !== signature) {
|
||||
res.status(400).json({
|
||||
error:
|
||||
"Invalid signature; server may have restarted since challenge was issued. Please request a new challenge.",
|
||||
});
|
||||
const error =
|
||||
"Invalid signature; server may have restarted since challenge was issued. Please request a new challenge.";
|
||||
req.log.info({ error }, "Verification rejected");
|
||||
res.status(400).json({ error });
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.proxyKey && result.data.proxyKey !== config.proxyKey) {
|
||||
res.status(401).json({ error: "Invalid proxy password" });
|
||||
const error = "Invalid proxy password";
|
||||
req.log.info({ error }, "Verification rejected");
|
||||
res.status(401).json({ error, password: result.data.proxyKey });
|
||||
return;
|
||||
}
|
||||
|
||||
if (challenge.ip && challenge.ip !== ip) {
|
||||
req.log.warn("Attempt to verify from different IP address");
|
||||
res.status(400).json({
|
||||
error: "Solution must be verified from original IP address",
|
||||
});
|
||||
const error = "Solution must be verified from original IP address";
|
||||
req.log.info(
|
||||
{ error, challengeIp: challenge.ip, clientIp: ip },
|
||||
"Verification rejected"
|
||||
);
|
||||
res.status(400).json({ error });
|
||||
return;
|
||||
}
|
||||
|
||||
if (solves.has(signature)) {
|
||||
req.log.warn("Attempt to reuse signature");
|
||||
res.status(400).json({ error: "Reused signature" });
|
||||
const error = "Reused signature";
|
||||
req.log.info({ error }, "Verification rejected");
|
||||
res.status(400).json({ error });
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() > challenge.e) {
|
||||
req.log.warn("Verification took too long");
|
||||
res.status(400).json({ error: "Verification took too long" });
|
||||
const error = "Verification took too long";
|
||||
req.log.info({ error }, "Verification rejected");
|
||||
res.status(400).json({ error });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -285,7 +291,7 @@ router.post("/verify", async (req, res) => {
|
||||
const success = await verifySolution(challenge, solution, req.log);
|
||||
if (!success) {
|
||||
recentAttempts.set(ip, Date.now() + 1000 * 60 * 60 * 6);
|
||||
req.log.warn("Solution failed verification");
|
||||
req.log.warn("Bogus solution, client blocked");
|
||||
res.status(400).json({ error: "Solution failed verification" });
|
||||
return;
|
||||
}
|
||||
@@ -299,10 +305,12 @@ router.post("/verify", async (req, res) => {
|
||||
if (challenge.token) {
|
||||
const user = getUser(challenge.token);
|
||||
if (user) {
|
||||
user.expiresAt = Date.now() + config.powTokenHours * 60 * 60 * 1000;
|
||||
user.disabledAt = undefined;
|
||||
user.disabledReason = undefined;
|
||||
upsertUser(user);
|
||||
upsertUser({
|
||||
token: challenge.token,
|
||||
expiresAt: Date.now() + config.powTokenHours * 60 * 60 * 1000,
|
||||
disabledAt: null,
|
||||
disabledReason: null,
|
||||
});
|
||||
req.log.info(
|
||||
{ token: `...${challenge.token.slice(-5)}` },
|
||||
"Token refreshed"
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
</noscript>
|
||||
<style>
|
||||
#captcha-container {
|
||||
max-width: 500px;
|
||||
margin: 50px auto;
|
||||
max-width: 550px;
|
||||
margin: 20px auto;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
#captcha-container {
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
#captcha-progress-text {
|
||||
width: 100%;
|
||||
height: 18rem;
|
||||
height: 20rem;
|
||||
resize: vertical;
|
||||
font-family: monospace;
|
||||
}
|
||||
@@ -70,13 +70,22 @@
|
||||
height: 100%;
|
||||
background-color: #76c7c0;
|
||||
}
|
||||
|
||||
#copy-token {
|
||||
border: none;
|
||||
background: none;
|
||||
filter: saturate(0);
|
||||
padding: 0;
|
||||
}
|
||||
#copy-token:hover {
|
||||
filter: saturate(1);
|
||||
}
|
||||
</style>
|
||||
<div style="display: none" id="captcha-container">
|
||||
<p>
|
||||
Your device needs to perform a verification task before you can receive a token. This might take anywhere from a few
|
||||
seconds to a few minutes, depending on your device and the proxy's security settings.
|
||||
Your device needs to be verified before you can receive a token. This might take anywhere from a few seconds to a
|
||||
few minutes, depending on your device and the proxy's security settings.
|
||||
</p>
|
||||
<p>Click the button below to start.</p>
|
||||
<details>
|
||||
<summary>What is this?</summary>
|
||||
<p>
|
||||
@@ -107,18 +116,6 @@
|
||||
faster than the first one.
|
||||
</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>What is the "Workers" setting?</summary>
|
||||
<p>
|
||||
This controls how many CPU cores will be used to solve the verification task. If your device gets too hot or slows
|
||||
down too much during verification, reduce the number of workers.
|
||||
</p>
|
||||
<p>
|
||||
For fastest verification, set this to the number of physical CPU cores in your device. Setting more workers than
|
||||
you have actual cores will generally only slow down verification.
|
||||
</p>
|
||||
<p>If you don't understand what this means, leave it at the default setting.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Other important information</summary>
|
||||
<ul>
|
||||
@@ -134,15 +131,27 @@
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Settings</summary>
|
||||
<div>
|
||||
<label for="workers">Workers:</label>
|
||||
<input type="number" id="workers" value="1" min="1" max="32" onchange="spawnWorkers()" />
|
||||
</div>
|
||||
<p>
|
||||
This controls how many CPU cores will be used to solve the verification task. If your device gets too hot or slows
|
||||
down too much during verification, reduce the number of workers.
|
||||
</p>
|
||||
<p>
|
||||
For fastest verification, set this to the number of physical CPU cores in your device. Setting more workers than
|
||||
you have actual cores will generally only slow down verification.
|
||||
</p>
|
||||
<p>If you don't understand what this means, leave it at the default setting.</p>
|
||||
</details>
|
||||
<form id="captcha-form" style="display: none">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
|
||||
<input type="hidden" name="tokenLifetime" value="<%= tokenLifetime %>" />
|
||||
</form>
|
||||
<div id="captcha-control">
|
||||
<div>
|
||||
<label for="workers">Workers:</label>
|
||||
<input type="number" id="workers" value="1" min="1" max="32" onchange="spawnWorkers()" />
|
||||
</div>
|
||||
<button id="worker-control" onclick="toggleWorker()">Start verification</button>
|
||||
</div>
|
||||
<div id="captcha-progress-container" style="display: none">
|
||||
@@ -185,6 +194,9 @@
|
||||
function handleWorkerMessage(e) {
|
||||
switch (e.data.type) {
|
||||
case "progress":
|
||||
if (solution) {
|
||||
return;
|
||||
}
|
||||
totalHashes += e.data.hashes;
|
||||
reports++;
|
||||
break;
|
||||
@@ -206,13 +218,13 @@
|
||||
}
|
||||
workers.forEach((w, i) => {
|
||||
w.postMessage({ type: "stop" });
|
||||
setTimeout(() => w.terminate(), 1000 + i * 100)
|
||||
setTimeout(() => w.terminate(), 1000 + i * 100);
|
||||
});
|
||||
workers = [];
|
||||
active = false;
|
||||
solution = e.data.nonce;
|
||||
document.getElementById("captcha-result").textContent =
|
||||
"Verification completed. Submitting solution for verification...";
|
||||
"Solution found. Verifying with server...";
|
||||
document.getElementById("captcha-control").style.display = "none";
|
||||
submitVerification();
|
||||
break;
|
||||
@@ -233,6 +245,21 @@
|
||||
estimateProgress();
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
if (!navigator.clipboard) {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
textArea.remove();
|
||||
} else {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
alert("Copied to clipboard.");
|
||||
}
|
||||
|
||||
function loadNewChallenge(c, s) {
|
||||
const btn = document.getElementById("worker-control");
|
||||
btn.textContent = "Start verification";
|
||||
@@ -248,6 +275,7 @@
|
||||
startTime = 0;
|
||||
lastUpdateTime = 0;
|
||||
elapsedTime = 0;
|
||||
totalHashes = 0;
|
||||
const targetValue = challenge.d.slice(0, -1);
|
||||
const hashLength = challenge.hl;
|
||||
workFactor = Number(BigInt(2) ** BigInt(8 * hashLength) / BigInt(targetValue));
|
||||
@@ -329,52 +357,53 @@
|
||||
document.getElementById("captcha-progress").style.display = "none";
|
||||
document.getElementById("captcha-result").innerHTML = `
|
||||
<p style="color: green">Verification complete</p>
|
||||
<p>Your user token is: <code>${data.token}</code></p>
|
||||
<p>Your user token is: <code>${data.token}</code> <button id="copy-token" onclick="copyToClipboard('${data.token}')">📋</button></p>
|
||||
<p>Valid until: ${new Date(Date.now() + lifetime * 3600 * 1000).toLocaleString()}</p>
|
||||
`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(time) {
|
||||
if (time < 60) {
|
||||
return time.toFixed(1) + "s";
|
||||
} else if (time < 3600) {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return minutes + "m " + seconds + "s";
|
||||
} else {
|
||||
const hours = Math.floor(time / 3600);
|
||||
const minutes = Math.floor((time % 3600) / 60);
|
||||
return hours + "h " + minutes + "m";
|
||||
}
|
||||
}
|
||||
|
||||
function estimateProgress() {
|
||||
if (reports % workers.length !== 0) {
|
||||
// if (reports % workers.length !== 0) {
|
||||
// return;
|
||||
// }
|
||||
if (Date.now() - lastUpdateTime < 500) {
|
||||
return;
|
||||
}
|
||||
elapsedTime += (Date.now() - lastUpdateTime) / 1000;
|
||||
lastUpdateTime = Date.now();
|
||||
const hashRate = totalHashes / elapsedTime;
|
||||
const timeRemaining = (workFactor - totalHashes) / hashRate;
|
||||
const progress = 100 * (1 - Math.exp(-totalHashes / workFactor));
|
||||
|
||||
const formatTime = (time) => {
|
||||
if (time < 60) {
|
||||
return time.toFixed(1) + "s";
|
||||
} else if (time < 3600) {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return minutes + "m " + seconds + "s";
|
||||
} else {
|
||||
const hours = Math.floor(time / 3600);
|
||||
const minutes = Math.floor((time % 3600) / 60);
|
||||
return hours + "h " + minutes + "m";
|
||||
}
|
||||
};
|
||||
|
||||
// const progress = 100 * (1 - Math.exp(-totalHashes / workFactor));
|
||||
const p = 1 / workFactor;
|
||||
const odds = ((1 - p) ** totalHashes * 100).toFixed(2);
|
||||
const odds = ((1 - p) ** totalHashes * 100).toFixed(3);
|
||||
const progress = 100 - odds;
|
||||
|
||||
let note = "";
|
||||
if (odds < 33) {
|
||||
note = " (" + odds + "% odds of no solution yet)";
|
||||
}
|
||||
// let note = " (" + odds + "% odds of no solution yet)";
|
||||
|
||||
document.getElementById("captcha-progress").style.width = Math.min(progress, 100) + "%";
|
||||
document.querySelector("#captcha-progress>.progress").style.width = Math.min(progress, 100) + "%";
|
||||
document.getElementById("captcha-progress-text").value = `
|
||||
Solution probability: 1 in ${workFactor.toLocaleString()} hashes
|
||||
Hashes computed: ${totalHashes.toLocaleString()}${note}
|
||||
Hashes computed: ${totalHashes.toLocaleString()}
|
||||
Luckiness: ${odds}%
|
||||
Elapsed time: ${formatTime(elapsedTime)}
|
||||
Hash rate: ${hashRate.toFixed(2)} H/s
|
||||
Workers: ${workers.length}${isMobileWebkit ? " (iOS/iPadOS detected)" : ""}
|
||||
${active ? `Average time remaining: ${formatTime(timeRemaining)}` : "Verification task stopped"}`.trim();
|
||||
${active ? `Average time remaining: ${formatTime(timeRemaining)}` : "Verification stopped"}`.trim();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
<%- include("partials/shared_header", { title: "Request User Token" }) %>
|
||||
|
||||
<style>
|
||||
#request-buttons {
|
||||
#request-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
width: 400px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 20px 0;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#request-buttons button {
|
||||
margin: 0 10px;
|
||||
#request-container button {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#refresh-token-input {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h1>Request User Token</h1>
|
||||
@@ -28,37 +35,70 @@
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
<div id="existing-token" style="display: none">
|
||||
<p>It looks like you might have an older temporary user token. If it has expired, you can try to refresh it.</p>
|
||||
<strong id="existing-token-value">Existing token:</strong>
|
||||
<div id="request-container">
|
||||
<button id="request-token" onclick="requestChallenge('new')">Get a new token</button>
|
||||
<button id="refresh-token-toggle" onclick="switchSection('refresh')">Refresh an old token</button>
|
||||
<h6 id="existing-token-value" style="display: none">Existing token:</h6>
|
||||
</div>
|
||||
<div id="request-buttons">
|
||||
<button disabled id="refresh-token" onclick="requestChallenge('refresh')">Refresh old token</button>
|
||||
<button id="request_token" onclick="requestChallenge('new')">Request new token</button>
|
||||
<div id="back-to-menu" style="display: none">
|
||||
<a href="#" onclick="switchSection('root')">« Back</a>
|
||||
</div>
|
||||
<div id="refresh-container" style="display: none">
|
||||
<div id="existing-token">
|
||||
<p>
|
||||
If you have an existing or expired token, enter it here to try to refresh it by completing a shorter verification.
|
||||
</p>
|
||||
<div>
|
||||
<label for="refresh-token-input">Existing token:</label>
|
||||
<input type="text" id="refresh-token-input" />
|
||||
<button id="refresh-token" onclick="requestChallenge('refresh')">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include("partials/user_challenge_widget") %>
|
||||
<script>
|
||||
function switchSection(sectionId) {
|
||||
const backToMenu = document.getElementById("back-to-menu");
|
||||
const captchaSection = document.getElementById("captcha-container");
|
||||
const requestSection = document.getElementById("request-container");
|
||||
const refreshSection = document.getElementById("refresh-container");
|
||||
[backToMenu, captchaSection, requestSection, refreshSection].forEach((element) => (element.style.display = "none"));
|
||||
switch (sectionId) {
|
||||
case "root":
|
||||
requestSection.style.display = "flex";
|
||||
maybeLoadExistingToken();
|
||||
break;
|
||||
case "captcha":
|
||||
captchaSection.style.display = "block";
|
||||
backToMenu.style.display = "block";
|
||||
break;
|
||||
case "refresh":
|
||||
refreshSection.style.display = "block";
|
||||
backToMenu.style.display = "block";
|
||||
document.getElementById("refresh-token-input").focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function requestChallenge(action) {
|
||||
const token = localStorage.getItem("captcha-temp-token");
|
||||
if (token && action === "new") {
|
||||
const data = JSON.parse(token);
|
||||
const { expires } = data;
|
||||
const expiresDate = new Date(expires);
|
||||
const now = new Date();
|
||||
if (expiresDate > now) {
|
||||
if (!confirm("You already have an existing token. Are you sure you want to request a new one?")) {
|
||||
return;
|
||||
}
|
||||
localStorage.removeItem("captcha-temp-token");
|
||||
document.getElementById("existing-token").style.display = "none";
|
||||
document.getElementById("refresh-token").disabled = true;
|
||||
const savedToken = localStorage.getItem("captcha-temp-token");
|
||||
const refreshInput = document.getElementById("refresh-token-input").value;
|
||||
if (savedToken && action === "new") {
|
||||
const confirmation = confirm(
|
||||
"It looks like you might already have an existing token. Are you sure you want to request a new one?"
|
||||
);
|
||||
if (!confirmation) {
|
||||
return;
|
||||
}
|
||||
} else if (!token && action === "refresh") {
|
||||
alert("You don't have an existing token to refresh");
|
||||
localStorage.removeItem("captcha-temp-token");
|
||||
document.getElementById("existing-token").style.display = "none";
|
||||
document.getElementById("refresh-token").disabled = true;
|
||||
} else if (!refreshInput?.length && action === "refresh") {
|
||||
alert("You need to provide a token to refresh.");
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshToken = token && action === "refresh" ? JSON.parse(token).token : undefined;
|
||||
const refreshToken = action === "refresh" ? refreshInput : undefined;
|
||||
const keyInput = document.getElementById("proxy-key");
|
||||
const proxyKey = (keyInput && keyInput.value) || undefined;
|
||||
if (!proxyKey?.length) {
|
||||
@@ -79,7 +119,7 @@
|
||||
}
|
||||
const { challenge, signature } = data;
|
||||
loadNewChallenge(challenge, signature);
|
||||
document.getElementById("request-buttons").style.display = "none";
|
||||
switchSection("captcha");
|
||||
})
|
||||
.catch(function (error) {
|
||||
console.error(error);
|
||||
@@ -87,22 +127,26 @@
|
||||
});
|
||||
}
|
||||
|
||||
const existingToken = localStorage.getItem("captcha-temp-token");
|
||||
if (existingToken) {
|
||||
const data = JSON.parse(existingToken);
|
||||
const { token, expires } = data;
|
||||
const expiresDate = new Date(expires);
|
||||
document.getElementById(
|
||||
"existing-token-value"
|
||||
).textContent = `Your token: ${token} (valid until ${expiresDate.toLocaleString()})`;
|
||||
document.getElementById("existing-token").style.display = "block";
|
||||
document.getElementById("refresh-token").disabled = false;
|
||||
function maybeLoadExistingToken() {
|
||||
const existingToken = localStorage.getItem("captcha-temp-token");
|
||||
if (existingToken) {
|
||||
const data = JSON.parse(existingToken);
|
||||
const { token, expires } = data;
|
||||
const expiresDate = new Date(expires);
|
||||
document.getElementById(
|
||||
"existing-token-value"
|
||||
).textContent = `User token: ${token} (valid until ${expiresDate.toLocaleString()})`;
|
||||
document.getElementById("existing-token-value").style.display = "block";
|
||||
document.getElementById("refresh-token-input").value = token;
|
||||
}
|
||||
|
||||
const proxyKey = localStorage.getItem("captcha-proxy-key");
|
||||
if (proxyKey && document.getElementById("proxy-key")) {
|
||||
document.getElementById("proxy-key").value = proxyKey;
|
||||
}
|
||||
}
|
||||
|
||||
const proxyKey = localStorage.getItem("captcha-proxy-key");
|
||||
if (proxyKey && document.getElementById("proxy-key")) {
|
||||
document.getElementById("proxy-key").value = proxyKey;
|
||||
}
|
||||
switchSection("root");
|
||||
</script>
|
||||
|
||||
<%- include("partials/user_footer") %>
|
||||
|
||||
Reference in New Issue
Block a user